diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04fb792..e9593c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: - run: pnpm install - name: Lint - run: pnpm exec oxlint -c .oxlintrc.json core/src/ cli/src/ durable-streams/ durable-effects/ runtime/ + run: pnpm exec oxlint -c .oxlintrc.json core/src/ cli/src/ durable-streams/ durable-effects/ runtime/ testing/ - name: Format - run: pnpm exec oxfmt --check core/src/ cli/src/ durable-streams/ durable-effects/ runtime/ vendor/ test-support/ + run: pnpm exec oxfmt --check core/src/ cli/src/ durable-streams/ durable-effects/ runtime/ testing/ vendor/ test-support/ test-deno: runs-on: ubuntu-latest @@ -47,6 +47,25 @@ jobs: - name: Test run: deno task test + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3 + with: + deno-version: v2.9.1 + + - name: Build the xmd binary + run: deno task build + + - name: Smoke test the compiled binary + run: | + ./dist/xmd test smoke-test/README.md \ + --component-dir smoke-test \ + --component-dir core/components \ + --raw + site: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index d8057c0..8cf5276 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -30,7 +30,7 @@ jobs: - name: Validate the manifests declare this version run: | VERSION="${{ steps.resolve.outputs.value }}" - for f in durable-streams/deno.json runtime/deno.json core/deno.json cli/deno.json packages/code-review-agent/deno.json; do + for f in durable-streams/deno.json runtime/deno.json core/deno.json testing/deno.json cli/deno.json packages/code-review-agent/deno.json; do declared="$(jq -r .version "$f")" if [ "$declared" != "$VERSION" ]; then echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests" @@ -82,9 +82,16 @@ jobs: package: core version: ${{ needs.version.outputs.value }} - cli: + testing: needs: [version, core, durable-streams] uses: ./.github/workflows/publish-one.yml + with: + package: testing + version: ${{ needs.version.outputs.value }} + + cli: + needs: [version, core, durable-streams, testing] + uses: ./.github/workflows/publish-one.yml with: package: cli version: ${{ needs.version.outputs.value }} diff --git a/.npmrc b/.npmrc index 319e41e..190f758 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ strict-peer-dependencies=false +@jsr:registry=https://npm.jsr.io diff --git a/AGENTS.md b/AGENTS.md index 66e94e2..dc52251 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ Do not commit if any check fails. Fix the issue first, then re-run all three. require changes to specs/release-process-spec.md to match. 9. Prefer stateless generators - use a function when calling a function that returns an operation; Do not do this function*(arg) { return yield* generator(arg) } +10. Structure source through names and modules. Do not use decorative section-divider comments. ## PR Process diff --git a/README.md b/README.md index 69f57a7..a468d14 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ Each invocation requires a new path. If the path already exists, `xmd` exits wit ## Project layout -- `core/src/run-document.ts` - document entrypoint and durable import pipeline. +- `core/src/execute.ts` - document entrypoint and durable import pipeline. - `core/src/scanner.ts` - boundary scanner for components and executable fences. - `core/src/` - component expansion, eval/exec handling, modifiers, and sampling helpers. - `core/components/` - reusable provider and demo components. diff --git a/bun.lock b/bun.lock index 8c43fd3..f429c0f 100644 --- a/bun.lock +++ b/bun.lock @@ -40,7 +40,7 @@ }, "cli": { "name": "@executablemd/cli", - "version": "0.2.0", + "version": "0.3.1", "bin": { "xmd": "./src/cli.ts", }, @@ -48,6 +48,7 @@ "@effectionx/stream-helpers": "0.8.3", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/testing": "workspace:*", "configliere": "^0.2.3", "effection": "4.1.0-alpha.7", "zod": "^4.3.6", @@ -55,7 +56,7 @@ }, "core": { "name": "@executablemd/core", - "version": "0.2.0", + "version": "0.3.1", "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/converge": "0.1.4", @@ -83,7 +84,7 @@ }, "durable-streams": { "name": "@executablemd/durable-streams", - "version": "0.2.0", + "version": "0.3.1", "dependencies": { "@durable-streams/client": "^0.2.2", "effection": "4.1.0-alpha.7", @@ -91,11 +92,11 @@ }, "packages/code-review-agent": { "name": "@executablemd/code-review-agent", - "version": "0.2.0", + "version": "0.3.1", }, "runtime": { "name": "@executablemd/runtime", - "version": "0.2.0", + "version": "0.3.1", "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/fetch": "0.2.0", @@ -112,6 +113,19 @@ "expect": "^30.0.0", }, }, + "testing": { + "name": "@executablemd/testing", + "version": "0.3.1", + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/scope-eval": "0.1.3", + "@effectionx/timebox": "0.4.3", + "@executablemd/core": "workspace:*", + "@executablemd/durable-streams": "workspace:*", + "@std/assert": "npm:@jsr/std__assert@^1.0.17", + "effection": "4.1.0-alpha.7", + }, + }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "7.28.5", "js-tokens": "4.0.0", "picocolors": "1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -210,6 +224,8 @@ "@executablemd/runtime": ["@executablemd/runtime@workspace:runtime"], + "@executablemd/testing": ["@executablemd/testing@workspace:testing"], + "@jest/diff-sequences": ["@jest/diff-sequences@30.3.0", "", {}, "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA=="], "@jest/expect-utils": ["@jest/expect-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA=="], @@ -224,6 +240,8 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jsr/std__internal": ["@jsr/std__internal@1.0.14", "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.14.tgz", {}, "sha512-JT8b/t40WcR9q0GDwRZUooY7aXeXnFal8iidE/5TckdAFtvpVAsaJ71/Xgf1SfoChs41s6CZkuCYM8toaNgdvA=="], + "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="], @@ -308,6 +326,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@std/assert": ["@jsr/std__assert@1.0.19", "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.19.tgz", { "dependencies": { "@jsr/std__internal": "^1.0.12" } }, "sha512-pEj6RPkGbqlgRmyKwATp4cUs6+ijxtdrv3bq8v1d2I2CEcMEyPaO8cVKro61wGRDH4cNg8Zx6haztvK/9m7gkA=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "2.1.0" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], diff --git a/bunfig.toml b/bunfig.toml index 8743232..b6b4282 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,5 @@ [test] tsconfig-override = "tsconfig.node.json" + +[install.scopes] +"@jsr" = "https://npm.jsr.io" diff --git a/cli/deno.json b/cli/deno.json index d5ea873..3b1edd8 100644 --- a/cli/deno.json +++ b/cli/deno.json @@ -4,6 +4,7 @@ "exports": "./src/cli.ts", "imports": { "@executablemd/core": "../core/mod.ts", - "zod": "npm:zod@^4.3.6" + "zod": "npm:zod@^4.3.6", + "@executablemd/testing": "../testing/mod.ts" } } diff --git a/cli/package.json b/cli/package.json index 7713dac..1a2e66e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -11,6 +11,7 @@ "@effectionx/stream-helpers": "0.8.3", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/testing": "workspace:*", "configliere": "^0.2.3", "effection": "4.1.0-alpha.7", "zod": "^4.3.6" diff --git a/cli/src/cli.ts b/cli/src/cli.ts old mode 100644 new mode 100755 index 8126c93..ea3e720 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -25,7 +25,8 @@ import { inspect } from "node:util"; import process from "node:process"; import { program, object, field, cli, commands, type Mods } from "configliere"; import { z } from "zod"; -import { runDocument, useNormalizedOutput, useTerminalOutput } from "@executablemd/core"; +import { execute, useNormalizedOutput, useTerminalOutput } from "@executablemd/core"; +import { installTestingVocabulary, TestFailureError, useTesting } from "@executablemd/testing"; import { FileStream } from "./file-stream.ts"; import denoJson from "../deno.json" with { type: "json" }; @@ -66,10 +67,35 @@ const runConfig = object({ }, }); +const testConfig = object({ + docPath: { + description: "markdown document to test", + ...field(z.string(), cli.argument()), + }, + componentDir: { + description: "component search directory", + ...field(z.array(z.string()), defaults(["components", "."]), field.array()), + }, + verbose: { + description: "log journal entries to stderr", + aliases: ["-V"], + ...field(z.boolean(), defaults(false)), + }, + journal: { + description: "write a diagnostic JSONL trace (path must not exist)", + aliases: ["-j"], + ...field(z.string().optional()), + }, + raw: { + description: "output raw markdown without normalization or terminal formatting", + ...field(z.boolean(), defaults(false)), + }, +}); + const xmd = program({ name: "xmd", version: denoJson.version, - config: commands({ run: runConfig }, { default: "run" }), + config: commands({ run: runConfig, test: testConfig }, { default: "run" }), }); // --------------------------------------------------------------------------- @@ -142,13 +168,16 @@ function* createJournalFile(filePath: string): Operation { yield* until(handle.close()); } -function* run(config: { - docPath: string; - componentDir: string[]; - verbose: boolean; - journal: string | undefined; - raw: boolean; -}): Operation { +function* run( + config: { + docPath: string; + componentDir: string[]; + verbose: boolean; + journal: string | undefined; + raw: boolean; + }, + mode: { testing: boolean }, +): Operation { const { docPath, componentDir, verbose, journal, raw } = config; // Every CLI invocation starts from an empty stream. --journal writes @@ -188,7 +217,7 @@ function* run(config: { // Output middleware (spec §9). // // Middleware is installed on the DocumentOutput Api via Api.around() before - // runDocument is called. runDocument owns the channel internally — + // execute is called. execute owns the output stream internally — // the CLI just installs transformations and consumes the returned stream. // --------------------------------------------------------------------------- @@ -200,9 +229,17 @@ function* run(config: { yield* useTerminalOutput(); } - // Run the document — returns a DocumentExecution. - // yield* execution waits for completion. execution.output streams chunks. - const execution = yield* runDocument({ + // Compose testing around the single core execution entrypoint: both + // commands register the vocabulary (assertions work in regular documents, + // explicit boundaries affect the outcome), while `xmd test` + // additionally activates root testing through a useTesting() session. + if (mode.testing) { + yield* useTesting({ verbose }); + } else { + yield* installTestingVocabulary({ verbose }); + } + + const execution = yield* execute({ docPath, stream, componentDirs: componentDir, @@ -227,6 +264,18 @@ function* run(config: { signal.close(); yield* writer; } + + // Inspect the completion Result AFTER the report finished streaming: + // test failures, assertion aborts, and any document abort exit nonzero. + const result = yield* execution; + if (!result.ok) { + if (result.error instanceof TestFailureError) { + console.error(`\ntests failed: ${result.error.message}`); + } else { + console.error(result.error.message); + } + yield* exit(1); + } } // --------------------------------------------------------------------------- @@ -254,7 +303,10 @@ await main(function* (args) { } switch (parsed.value.name) { case "run": - yield* run(parsed.value.config); + yield* run(parsed.value.config, { testing: false }); + break; + case "test": + yield* run(parsed.value.config, { testing: true }); break; } } diff --git a/core/mod.ts b/core/mod.ts index bbe4c5e..030d68f 100644 --- a/core/mod.ts +++ b/core/mod.ts @@ -24,6 +24,9 @@ export type { ResolveResult, SampleContext, Json, + SourcePosition, + InvocationContext, + InvocationHandling, } from "./src/types.ts"; export type { Workflow } from "@executablemd/durable-streams"; @@ -34,11 +37,7 @@ export { healSegment } from "./src/heal.ts"; export type { Middleware } from "@effectionx/middleware"; export { combine } from "@effectionx/middleware"; -export type { - ModifierFactory, - ModifierMiddleware, - CodeBlockWorkflow, -} from "./src/modifiers.ts"; +export type { ModifierFactory, ModifierMiddleware, CodeBlockWorkflow } from "./src/modifiers.ts"; export { useCodeBlock } from "./src/modifiers.ts"; // --------------------------------------------------------------------------- @@ -53,11 +52,25 @@ export { raise, env, evalScope, + expandInvocation, codeBlock, persistent, content, } from "./src/component-api.ts"; +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +export { renderSegments } from "./src/render.ts"; + +// --------------------------------------------------------------------------- +// Replay-safe stream +// --------------------------------------------------------------------------- + +export { createReplayStream } from "./src/replay-stream.ts"; +export type { ReplayStream } from "./src/replay-stream.ts"; + // --------------------------------------------------------------------------- // Eval system (generator eval blocks) // --------------------------------------------------------------------------- @@ -91,11 +104,7 @@ export { interpolateEvalBindings } from "./src/eval-interpolate.ts"; export { findFreePort } from "@executablemd/runtime"; export type { TransformResult } from "./src/eval-transform.ts"; -export { - transformBlock, - serializeExports, - isJson, -} from "./src/eval-transform.ts"; +export { transformBlock, serializeExports, isJson } from "./src/eval-transform.ts"; // --------------------------------------------------------------------------- // Output Api & middleware @@ -110,8 +119,8 @@ export { useTerminalOutput } from "./src/output/terminal.ts"; // Document runner // --------------------------------------------------------------------------- -export { runDocument } from "./src/run-document.ts"; -export type { RunDocumentOptions, DocumentExecution } from "./src/run-document.ts"; +export { execute, Execution } from "./src/execute.ts"; +export type { ExecuteOptions, ExecutionApi, DocumentExecution } from "./src/execute.ts"; export { useDenoCompiler } from "./src/deno-compiler.ts"; export { useTempFileCompiler } from "./src/temp-file-compiler.ts"; diff --git a/core/src/collect.ts b/core/src/collect.ts index 2e1a8d7..508a239 100644 --- a/core/src/collect.ts +++ b/core/src/collect.ts @@ -1,24 +1,28 @@ /** * collect — wait for a document execution to complete and return the output. * - * Convenience wrapper that `yield*`s the execution to get the full - * output string. Equivalent to `yield* execution` but reads more - * clearly at call sites. + * Convenience wrapper that unwraps the completion `Result`: + * the output string on `Ok`, a throw on `Err`. * * ```ts - * const output = yield* collect(yield* runDocument(options)); + * const output = yield* collect(yield* execute(options)); * ``` */ import type { Operation } from "effection"; -import type { DocumentExecution } from "./run-document.ts"; +import type { DocumentExecution } from "./execute.ts"; /** * Wait for a document execution to complete and return the full output. + * Throws the failure when the execution completed with `Err`. * - * @param execution - A `DocumentExecution` as returned by `runDocument`. + * @param execution - A `DocumentExecution` as returned by `execute`. * @returns The full output string. */ export function* collect(execution: DocumentExecution): Operation { - return yield* execution; + const result = yield* execution; + if (!result.ok) { + throw result.error; + } + return result.value; } diff --git a/core/src/component-api.ts b/core/src/component-api.ts index 321263c..6fc2e07 100644 --- a/core/src/component-api.ts +++ b/core/src/component-api.ts @@ -20,9 +20,12 @@ import type { CodeBlockContext, CodeBlockResult, ComponentDefinition, + ComponentInvocation, ErrorSegment, EvalEnv, FunctionComponentDefinition, + InvocationContext, + InvocationHandling, Modifier, } from "./types.ts"; @@ -38,6 +41,16 @@ export interface ComponentApi { raise(error: ErrorSegment): Operation; env: EvalEnv | undefined; evalScope: EvalScope | undefined; + /** + * Offer a raw component invocation to extensions before built-in expansion. + * Extensions install middleware that returns `{ segments }` for the names + * they claim and delegates to `next` for everything else. The default + * answers `undefined` — unhandled — so expansion proceeds normally. + */ + expandInvocation( + invocation: ComponentInvocation, + ctx: InvocationContext, + ): Operation; codeBlock(): Operation; /** Whether the current block runs with persistent resource lifetime. */ persistent: boolean; @@ -67,6 +80,10 @@ export const Component = createApi("Component", { env: undefined, evalScope: undefined, // deno-lint-ignore require-yield + *expandInvocation(): Operation { + return undefined; + }, + // deno-lint-ignore require-yield *codeBlock(): Operation { throw new Error( "Component.codeBlock() has no provider: no code block is executing in this scope.", @@ -87,6 +104,7 @@ export const { raise, env, evalScope, + expandInvocation, codeBlock, persistent, content, diff --git a/core/src/deno-compiler.ts b/core/src/deno-compiler.ts index e269d6e..0ef1cea 100644 --- a/core/src/deno-compiler.ts +++ b/core/src/deno-compiler.ts @@ -12,6 +12,11 @@ import { call } from "effection"; import type { Operation } from "effection"; import { API } from "@executablemd/runtime"; +// STANDARD_IMPORTS below resolve at runtime from generated eval modules; +// without these static anchors, `deno compile --exclude-unused-npm` prunes +// the packages from the binary and every eval block using them fails. +import "@effectionx/converge"; +import "@effectionx/fetch"; /** * Standard import statements prepended to every generated eval module. @@ -28,7 +33,7 @@ const STANDARD_IMPORTS = [ /** * Install the Deno data: URI compiler as middleware on the current scope. * - * Must be called inside an Effection scope (e.g., inside `runDocument`'s + * Must be called inside an Effection scope (e.g., inside `execute`'s * spawned task) before any eval blocks execute. */ export function* useDenoCompiler(): Operation { diff --git a/core/src/run-document.ts b/core/src/execute.ts similarity index 69% rename from core/src/run-document.ts rename to core/src/execute.ts index ba2feb2..2badf17 100644 --- a/core/src/run-document.ts +++ b/core/src/execute.ts @@ -1,15 +1,18 @@ /** - * Entry point — runDocument (spec §7). + * Entry point — execute (spec §7). * * Wires together the boundary scanner, component import, expansion engine, - * modifier system, and journal infrastructure. + * modifier system, and journal infrastructure. `execute` is delivered + * through the Execution Api so extensions can decorate the execution + * lifecycle as middleware. * * Journal runtime integration is concentrated at execution boundaries. * See DEC-005 in specs/decisions.md. */ -import { scoped, spawn, createChannel, withResolvers, until } from "effection"; -import type { Operation, Stream } from "effection"; +import { Err, Ok, scoped, spawn, withResolvers, until } from "effection"; +import type { Operation, Result, Stream } from "effection"; +import { createApi } from "@effectionx/context-api"; import { durableRun, createDurableOperation, @@ -18,6 +21,7 @@ import { } from "@executablemd/durable-streams"; import { exec, readTextFile, stat, cwd } from "@executablemd/runtime"; import type { Workflow, Json } from "@executablemd/durable-streams"; +import { createReplayStream } from "./replay-stream.ts"; import { useDenoCompiler } from "./deno-compiler.ts"; import { useTempFileCompiler } from "./temp-file-compiler.ts"; import type { @@ -58,10 +62,10 @@ import { Stdio } from "@effectionx/process"; import matter from "gray-matter"; // --------------------------------------------------------------------------- -// runDocument options (spec §7.1) +// execute options (spec §7.1) // --------------------------------------------------------------------------- -export interface RunDocumentOptions { +export interface ExecuteOptions { /** Path to the root markdown document (workspace-relative). */ docPath: string; @@ -140,7 +144,28 @@ function* durableImportComponent( // Markdown component: parse at runtime — deterministic from content const parsed = matter(result.content); const { meta, inputs } = parseFrontmatter(parsed.data as Record); - const bodySegments = scanSegments(parsed.content); + // The markdown body is a verbatim suffix of the raw file, so the body start + // is computed by length — never by content search, which could false-match + // body text repeated inside frontmatter. The invariant check turns any + // gray-matter normalization surprise into a loud error instead of silently + // wrong source positions. + const bodyStart = result.content.length - parsed.content.length; + if (result.content.slice(bodyStart) !== parsed.content) { + throw new Error( + `frontmatter parse did not preserve the markdown body verbatim: ${result.path}`, + ); + } + let baseLine = 1; + for (let i = 0; i < bodyStart; i++) { + if (result.content[i] === "\n") { + baseLine++; + } + } + const bodySegments = scanSegments(parsed.content, { + path: result.path, + baseOffset: bodyStart, + baseLine, + }); return { kind: "markdown" as const, @@ -251,7 +276,7 @@ const silentFactory: ModifierFactory = (_params) => (_args, next) => function* documentWorkflow(): Workflow { // Import root — same pipeline as any component. The provider middleware - // installed by runDocument maps "__root__" to the document path. The + // installed by execute maps "__root__" to the document path. The // ephemeral() wrapper bridges typing only — the import inside remains a // durable, journaled operation. const root = yield* ephemeral(importComponent("__root__")); @@ -331,58 +356,55 @@ function* documentWorkflow(): Workflow { } // --------------------------------------------------------------------------- -// runDocument (spec §7.1) +// execute (spec §7.1) // --------------------------------------------------------------------------- /** * A running document execution. * - * `yield* execution` waits for the workflow to complete and returns - * the full output string (or throws on error). + * `yield* execution` waits for completion and returns a `Result`: + * `Ok(output)` on success, `Err(error)` on document, infrastructure, or + * policy failure. Completion never throws once the handle exists. * - * `execution.output` is a `Stream` for consuming - * chunks as they arrive. The close value is the full output. + * `execution.output` is a `Stream` for consuming chunks as + * they arrive. The close value is the complete rendered output — or the + * partial output rendered before a failure. */ -export interface DocumentExecution extends Operation { - /** Stream of output chunks. Close value is the full output. */ +export interface DocumentExecution extends Operation> { + /** Stream of output chunks. Close value is the full (or partial) output. */ output: Stream; } /** * Execute a markdown document as a durable workflow. * - * Returns a `DocumentExecution` — an operation you can `yield*` to - * get the full output, with a `.output` stream for chunk-by-chunk - * consumption. + * Returns a `DocumentExecution` — an operation you can `yield*` for the + * completion `Result`, with a `.output` stream for chunk-by-chunk + * consumption. Once the handle exists, completion never throws: every + * later failure — document, infrastructure, or policy middleware — closes + * `output` (with the complete or partial rendered text) and resolves + * `Err(error)`. * - * Simple — just get the output: + * Simple — get the outcome: * * ```ts - * const execution = yield* runDocument(options); - * const output = yield* execution; + * const execution = yield* execute(options); + * const result = yield* execution; + * if (result.ok) { + * console.log(result.value); + * } * ``` * * Streaming — consume chunks as they arrive: * * ```ts - * const execution = yield* runDocument(options); + * const execution = yield* execute(options); * const output = yield* forEach(function* (chunk) { * process.stdout.write(chunk); * }, execution.output); * ``` - * - * Error handling: - * - * ```ts - * const execution = yield* runDocument(options); - * try { - * const output = yield* execution; - * } catch (e) { - * // workflow error - * } - * ``` */ -export function* runDocument(options: RunDocumentOptions): Operation { +function* executeDocument(options: ExecuteOptions): Operation { const { docPath, stream, @@ -414,63 +436,70 @@ export function* runDocument(options: RunDocumentOptions): Operation(); - const { operation, resolve, reject } = withResolvers(); + // Replay-safe transport: late subscribers receive every chunk and the + // close value, so subscription readiness before first emission is never + // required (spec §9; see replay-stream.ts). + const channel = createReplayStream(); + const { operation, resolve } = withResolvers>(); yield* spawn(function* () { let emitted = false; + let emittedText = ""; - // Install platform-appropriate compiler middleware - // deno-lint-ignore no-explicit-any - if (typeof (globalThis as any).Deno !== "undefined") { - yield* useDenoCompiler(); - } else { - yield* useTempFileCompiler(); - } + // The ENTIRE setup and workflow sit inside one error boundary: once the + // handle exists, any failure closes output (with whatever rendered so + // far) and resolves Err — completion never throws. + try { + // Install platform-appropriate compiler middleware + // deno-lint-ignore no-explicit-any + if (typeof (globalThis as any).Deno !== "undefined") { + yield* useDenoCompiler(); + } else { + yield* useTempFileCompiler(); + } - // DocumentOutput → channel bridge (innermost middleware — output flows - // through caller-installed normalize/terminal middleware first, then here). - yield* DocumentOutput.around({ - *output([text]) { - emitted = true; - yield* channel.send(text); - }, - }); - - yield* Stdio.around({ - *stdout() {}, - *stderr() {}, - }); - - // Create per-document eval scope (spec §3.1). - // Created in the same scope as durableRun so that DurableCtx - // (set by durableRun) is visible to eval code that calls - // renderChildren → importComponent → createDurableOperation. - const rootEvalScope = yield* useEvalScope(); - - // Install the document's runtime Component providers before durableRun - // so the workflow inherits them: component import, modifier execution, - // and the root eval scope. - yield* Component.around( - { - *importComponent([name], _next) { - return yield* durableImportComponent( - name, - name === "__root__" ? docPath : undefined, - componentDirs, - ); + // DocumentOutput → channel bridge (innermost middleware — output flows + // through caller-installed normalize/terminal middleware first, then here). + yield* DocumentOutput.around({ + *output([text]) { + emitted = true; + emittedText += text; + yield* channel.send(text); }, - *applyModifiers([modifiers, context], _next) { - const chain = composeModifierChain(modifiers, context, registry); - return yield* chain(); + }); + + yield* Stdio.around({ + *stdout() {}, + *stderr() {}, + }); + + // Create per-document eval scope (spec §3.1). + // Created in the same scope as durableRun so that DurableCtx + // (set by durableRun) is visible to eval code that calls + // renderChildren → importComponent → createDurableOperation. + const rootEvalScope = yield* useEvalScope(); + + // Install the document's runtime Component providers before durableRun + // so the workflow inherits them: component import, modifier execution, + // and the root eval scope. + yield* Component.around( + { + *importComponent([name], _next) { + return yield* durableImportComponent( + name, + name === "__root__" ? docPath : undefined, + componentDirs, + ); + }, + *applyModifiers([modifiers, context], _next) { + const chain = composeModifierChain(modifiers, context, registry); + return yield* chain(); + }, + evalScope: () => rootEvalScope, }, - evalScope: () => rootEvalScope, - }, - { at: "min" }, - ); + { at: "min" }, + ); - // Run the durable workflow. - try { const output = yield* durableRun(() => documentWorkflow(), { stream, }); @@ -482,10 +511,12 @@ export function* runDocument(options: RunDocumentOptions): Operation; +} + +export const Execution = createApi("Execution", { + *execute(options: ExecuteOptions): Operation { + return yield* executeDocument(options); + }, +}); + +export const { execute } = Execution.operations; diff --git a/core/src/expand.ts b/core/src/expand.ts index a98558a..16fa10e 100644 --- a/core/src/expand.ts +++ b/core/src/expand.ts @@ -33,6 +33,7 @@ import { applyModifiers, env, evalScope, + expandInvocation, importComponent, raise, } from "./component-api.ts"; @@ -122,6 +123,26 @@ export function* expandSegments( } case "component": { + // Extension hook: an installed vocabulary may claim this invocation + // before built-in expansion. Returned error segments follow the + // ambient raise policy, like any component-produced error. + const handling = yield* expandInvocation(segment, { + meta: parentMeta, + props: parentProps, + projectedEnv: segment.projectedEnv, + expand: (segments) => expandSegments(segments, parentMeta, parentProps, hideSet, counter), + }); + if (handling) { + for (const handled of handling.segments) { + if (handled.type === "error") { + result.push(yield* raise(handled)); + } else { + result.push(handled); + } + } + break; + } + if (segment.name === "Output") { // Definition-owned is consumed by buildBody before it // reaches here. Reaching this branch means a misplaced or diff --git a/core/src/replay-stream.ts b/core/src/replay-stream.ts new file mode 100644 index 0000000..4fb8455 --- /dev/null +++ b/core/src/replay-stream.ts @@ -0,0 +1,70 @@ +/** + * Replay-safe stream — the document output transport (spec §9). + * + * An unbuffered channel drops values sent before a consumer subscribes. The + * replay stream retains its full event history so late subscribers receive + * every chunk and the close value, and multiple subscribers each get the + * complete sequence exactly once. + * + * Race-free by construction: delivery appends to the history and forwards to + * every registered subscriber queue in one synchronous step, and a new + * subscription replays the history into its own queue and registers for live + * events before yielding — no event can fall between replay and registration. + */ + +import { createQueue, ensure } from "effection"; +import type { Operation, Queue, Stream } from "effection"; + +export interface ReplayStream extends Stream { + send(value: T): Operation; + close(value: TClose): Operation; +} + +export function createReplayStream(): ReplayStream { + type Event = IteratorResult; + + const history: Event[] = []; + const subscribers = new Set>(); + let closed = false; + + function replay(queue: Queue, event: Event) { + if (event.done) { + queue.close(event.value); + } else { + queue.add(event.value); + } + } + + function deliver(event: Event) { + if (closed) { + return; + } + closed = event.done === true; + history.push(event); + for (const queue of subscribers) { + replay(queue, event); + } + } + + return { + // deno-lint-ignore require-yield + *send(value: T) { + deliver({ done: false, value }); + }, + // deno-lint-ignore require-yield + *close(value: TClose) { + deliver({ done: true, value }); + }, + *[Symbol.iterator]() { + const queue = createQueue(); + for (const event of history) { + replay(queue, event); + } + subscribers.add(queue); + yield* ensure(() => { + subscribers.delete(queue); + }); + return queue; + }, + }; +} diff --git a/core/src/scanner.ts b/core/src/scanner.ts index 4c5a02e..9eede19 100644 --- a/core/src/scanner.ts +++ b/core/src/scanner.ts @@ -17,6 +17,7 @@ import type { Modifier, ParsedInfoString, Json, + SourcePosition, } from "./types.ts"; // --------------------------------------------------------------------------- @@ -67,13 +68,69 @@ export function parseInfoString(infoString: string): ParsedInfoString { // Segment scanner // --------------------------------------------------------------------------- +/** + * Where scanned text sits in its original file. `baseOffset`/`baseLine` + * translate body-relative positions to original-file positions (frontmatter + * included). Omitted for dynamically scanned strings. + */ +export interface SourceOrigin { + path: string; + baseOffset: number; + baseLine: number; +} + +/** + * Positioning state threaded through the scan: the origin translation plus a + * precomputed line-start index so per-tag position lookups avoid rescanning. + */ +interface PositionIndex { + origin?: SourceOrigin; + lineStarts: number[]; +} + +function computeLineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") { + starts.push(i + 1); + } + } + return starts; +} + +function positionAt(index: PositionIndex, offset: number): SourcePosition { + const { lineStarts, origin } = index; + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { + const mid = (low + high + 1) >> 1; + if (lineStarts[mid]! <= offset) { + low = mid; + } else { + high = mid - 1; + } + } + const localLine = low + 1; + const column = offset - lineStarts[low]! + 1; + return { + path: origin?.path, + offset: (origin?.baseOffset ?? 0) + offset, + line: (origin?.baseLine ?? 1) + localLine - 1, + column, + }; +} + /** * Scan raw markdown text into segments. * * Identifies component invocations (``), executable code * blocks (fenced with `exec` in info string), and everything else as text. + * + * When `origin` is provided, component invocations carry `position` values + * expressed in the original file's coordinates. */ -export function scanSegments(text: string): Segment[] { +export function scanSegments(text: string, origin?: SourceOrigin): Segment[] { + const index: PositionIndex = { origin, lineStarts: computeLineStarts(text) }; const segments: Segment[] = []; let pos = 0; let textStart = 0; @@ -128,7 +185,7 @@ export function scanSegments(text: string): Segment[] { // Check for component invocation: `<` followed by uppercase letter if (text[pos] === "<" && pos + 1 < text.length && /[A-Z]/.test(text[pos + 1]!)) { // Make sure we're not inside a fenced code block (handled above) - const component = parseComponentTag(text, pos); + const component = parseComponentTag(text, pos, index); if (component) { // Flush text before component if (pos > textStart) { @@ -281,7 +338,11 @@ interface ParsedComponent { * Returns the parsed component invocation and the position after it, * or null if this is not a valid component tag. */ -function parseComponentTag(text: string, start: number): ParsedComponent | null { +function parseComponentTag( + text: string, + start: number, + index: PositionIndex, +): ParsedComponent | null { let pos = start + 1; // Skip '<' // Parse tag name — must start with uppercase, can contain dots @@ -316,6 +377,7 @@ function parseComponentTag(text: string, start: number): ParsedComponent | null expressions, children: [], selfClosing: true, + position: positionAt(index, start), }, end: pos, }; @@ -326,7 +388,7 @@ function parseComponentTag(text: string, start: number): ParsedComponent | null pos++; // Skip '>' // Parse children until closing tag - const { children, end: childEnd } = parseChildren(text, pos, name); + const { children, end: childEnd } = parseChildren(text, pos, name, index); if (childEnd === -1) return null; return { @@ -337,6 +399,7 @@ function parseComponentTag(text: string, start: number): ParsedComponent | null expressions, children, selfClosing: false, + position: positionAt(index, start), }, end: childEnd, }; @@ -595,7 +658,12 @@ interface ParsedChildren { end: number; // position after closing tag } -function parseChildren(text: string, start: number, tagName: string): ParsedChildren { +function parseChildren( + text: string, + start: number, + tagName: string, + index: PositionIndex, +): ParsedChildren { let pos = start; let textStart = pos; const children: Segment[] = []; @@ -658,7 +726,7 @@ function parseChildren(text: string, start: number, tagName: string): ParsedChil // Check for nested component if (text[pos] === "<" && pos + 1 < text.length && /[A-Z]/.test(text[pos + 1]!)) { - const nested = parseComponentTag(text, pos); + const nested = parseComponentTag(text, pos, index); if (nested) { if (pos > textStart) { pushText(children, text.slice(textStart, pos)); diff --git a/core/src/temp-file-compiler.ts b/core/src/temp-file-compiler.ts index 06aa015..7391a9b 100644 --- a/core/src/temp-file-compiler.ts +++ b/core/src/temp-file-compiler.ts @@ -8,12 +8,17 @@ * Standard imports (Effection, executable.md APIs) are captured in the middleware * closure — they are not part of the Compiler API interface. * - * Installed automatically by `runDocument` when running on Node or Bun. + * Installed automatically by `execute` when running on Node or Bun. */ import { call } from "effection"; import type { Operation } from "effection"; import { API } from "@executablemd/runtime"; +// STANDARD_IMPORTS below resolve at runtime from generated eval modules; +// without these static anchors, `deno compile --exclude-unused-npm` prunes +// the packages from the binary and every eval block using them fails. +import "@effectionx/converge"; +import "@effectionx/fetch"; import { writeFile, unlink, mkdir } from "node:fs/promises"; import { resolve } from "node:path"; import { randomUUID } from "node:crypto"; diff --git a/core/src/types.ts b/core/src/types.ts index 534ae60..97659f3 100644 --- a/core/src/types.ts +++ b/core/src/types.ts @@ -5,6 +5,7 @@ * modifier system types, and shared interfaces. */ +import type { Operation } from "effection"; import type { Json as DurableJson, Workflow } from "@executablemd/durable-streams"; // --------------------------------------------------------------------------- @@ -51,6 +52,21 @@ export interface ComponentInvocation { * by substituteContent when projecting children into slots. */ projectedEnv?: { values: Record }; + /** + * Source location of the opening tag in the original file, frontmatter + * included. Absent for dynamically scanned strings (render(markdown)). + */ + position?: SourcePosition; +} + +/** A location in an original source file. Lines and columns are 1-based. */ +export interface SourcePosition { + /** Workspace-relative file path. Undefined for dynamically scanned text. */ + path?: string; + /** Character offset in the original file. */ + offset: number; + line: number; + column: number; } export interface ExecutableCodeBlock { @@ -88,6 +104,34 @@ export interface EvalEnv { values: Record; } +// --------------------------------------------------------------------------- +// Invocation-expansion extension hook +// --------------------------------------------------------------------------- + +/** + * Expansion context offered to `expandInvocation` extensions alongside the + * raw invocation. Exposes exactly what an extension needs: the parent scope's + * interpolation inputs, caller-projected bindings, and incremental expansion + * in the current context. Engine internals (hide set, block counter) stay + * captured inside the `expand` closure. + */ +export interface InvocationContext { + meta: Record; + props: Record; + /** Caller-projected bindings (segment.projectedEnv) for expression evaluation. */ + projectedEnv?: EvalEnv; + /** Incrementally expand segments in the current expansion context. */ + expand(segments: Segment[]): Operation; +} + +/** + * A claimed invocation's replacement segments. `{ segments: [] }` means + * handled-with-no-output — distinct from `undefined` (unhandled). + */ +export interface InvocationHandling { + segments: Segment[]; +} + // --------------------------------------------------------------------------- // Modifier system (spec §3.2–3.5) // --------------------------------------------------------------------------- diff --git a/core/tests/daemon-integration.test.ts b/core/tests/daemon-integration.test.ts index 316a057..56d1ff1 100644 --- a/core/tests/daemon-integration.test.ts +++ b/core/tests/daemon-integration.test.ts @@ -9,7 +9,7 @@ * evalScope.eval(). Tests must NOT rely on the daemon having written * to the filesystem before the next sequential block runs — that is a * race condition. Instead, tests verify deterministic properties: - * - runDocument completes (proves daemon cleanup works) + * - execute completes (proves daemon cleanup works) * - journal shape (no daemon entry) * - output shape (empty output from daemon blocks) * - error propagation via component-scoped daemon + children @@ -18,7 +18,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { race, sleep } from "effection"; import { InMemoryStream } from "@executablemd/durable-streams"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -51,9 +51,9 @@ function writeFiles(dir: string, files: Record): void { describe("Tier Q — Daemon integration", () => { // Q4/Q5: daemon forked into eval scope, process cleaned up on completion - // Verified by: runDocument completes without hanging (daemon terminated), + // Verified by: execute completes without hanging (daemon terminated), // and the daemon block produces no output. - it("Q4/Q5: daemon process forked and cleaned up — runDocument completes", function* () { + it("Q4/Q5: daemon process forked and cleaned up — execute completes", function* () { const tmpDir = makeTempDir(); try { @@ -63,13 +63,13 @@ describe("Tier Q — Daemon integration", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), ); - // runDocument completed — daemon was terminated when scope closed. + // execute completed — daemon was terminated when scope closed. // If daemon wasn't cleaned up, this test would hang for 300s. expect(output).toContain("before"); expect(output).toContain("after"); @@ -91,7 +91,7 @@ describe("Tier Q — Daemon integration", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -118,7 +118,7 @@ describe("Tier Q — Daemon integration", () => { const stream = new InMemoryStream(); yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -141,9 +141,9 @@ describe("Tier Q — Daemon integration", () => { // evalScope.eval(), the DaemonExitError propagates through the eval // scope. At the root document level (no component wrapper), subsequent // blocks may or may not see the error depending on timing. - // Test: daemon that exits immediately still allows runDocument to complete, + // Test: daemon that exits immediately still allows execute to complete, // and the output contains some indication (error or normal completion). - it("Q8: premature daemon exit — runDocument completes without hanging", function* () { + it("Q8: premature daemon exit — execute completes without hanging", function* () { const tmpDir = makeTempDir(); try { @@ -153,13 +153,13 @@ describe("Tier Q — Daemon integration", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), ); - // The key property: runDocument completes without hanging. + // The key property: execute completes without hanging. // The daemon exited immediately. The output should contain // surrounding text — the daemon's error may or may not appear // depending on the race between daemon exit and block processing. @@ -196,13 +196,13 @@ describe("Tier Q — Daemon integration", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), ); - // runDocument completed successfully — the daemon received a valid + // execute completed successfully — the daemon received a valid // interpolated command. If interpolation failed, {marker} would be // passed verbatim, but the command would still be valid bash. // The key test: no ERROR in output (daemon started successfully). @@ -232,7 +232,7 @@ describe("Tier Q — Daemon integration", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -241,7 +241,7 @@ describe("Tier Q — Daemon integration", () => { // The eval block error should appear in output expect(output).toContain("intentional error"); - // runDocument completed without hanging — daemon was cleaned up + // execute completed without hanging — daemon was cleaned up // by structured concurrency when the scope closed. } finally { cleanup(tmpDir); @@ -272,7 +272,7 @@ describe("Tier Q — Daemon integration", () => { const stream = new InMemoryStream(); // Golden run const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -284,7 +284,7 @@ describe("Tier Q — Daemon integration", () => { // Replay — eval block replays from journal (restoring tag to env.values), // daemon spawns a fresh process with the restored interpolated value. const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -322,12 +322,12 @@ describe("Tier Q — Daemon integration", () => { const stream = new InMemoryStream(); - // Race runDocument against a short sleep. The sleep wins, - // cancelling the runDocument scope (and the daemon within it). + // Race execute against a short sleep. The sleep wins, + // cancelling the execute scope (and the daemon within it). // If daemon cleanup is broken, race would hang for 300s. const result = yield* race([ collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -335,7 +335,7 @@ describe("Tier Q — Daemon integration", () => { sleep(500), ]); - // sleep(500) returns void, collect(runDocument) returns string. + // sleep(500) returns void, collect(execute) returns string. // If sleep won (expected), result is undefined. // Either way, the test passed — race resolved without hanging. expect(true).toBe(true); diff --git a/core/tests/eval-bindings.test.ts b/core/tests/eval-bindings.test.ts index 2e9018e..cb6d10f 100644 --- a/core/tests/eval-bindings.test.ts +++ b/core/tests/eval-bindings.test.ts @@ -8,7 +8,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import { useStubFs, useEchoExec } from "@executablemd/runtime/test"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; describe("Tier T5 — Binding environment", () => { @@ -23,7 +23,7 @@ describe("Tier T5 — Binding environment", () => { // Should not throw — port is available in block 2 const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -46,7 +46,7 @@ describe("Tier T5 — Binding environment", () => { // Should succeed — x is 2 in block 3 (shadowed by block 2) const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -64,7 +64,7 @@ describe("Tier T5 — Binding environment", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -82,7 +82,7 @@ describe("Tier T5 — Binding environment", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -100,7 +100,7 @@ describe("Tier T5 — Binding environment", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), diff --git a/core/tests/eval-durable.test.ts b/core/tests/eval-durable.test.ts index 17e53e3..65f96c9 100644 --- a/core/tests/eval-durable.test.ts +++ b/core/tests/eval-durable.test.ts @@ -10,7 +10,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import { useStubFs, useEchoExec } from "@executablemd/runtime/test"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; describe("Tier T4 — eval factory and journal integration", () => { @@ -24,7 +24,7 @@ describe("Tier T4 — eval factory and journal integration", () => { yield* useEchoExec(); yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -46,7 +46,7 @@ describe("Tier T4 — eval factory and journal integration", () => { // Golden run const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -54,7 +54,7 @@ describe("Tier T4 — eval factory and journal integration", () => { // Replay const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -73,7 +73,7 @@ describe("Tier T4 — eval factory and journal integration", () => { yield* useEchoExec(); const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -81,7 +81,7 @@ describe("Tier T4 — eval factory and journal integration", () => { // Replay const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -100,7 +100,7 @@ describe("Tier T4 — eval factory and journal integration", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -120,7 +120,7 @@ describe("Tier T4 — eval factory and journal integration", () => { yield* useEchoExec(); yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -140,7 +140,7 @@ describe("Tier T4 — eval factory and journal integration", () => { yield* useEchoExec(); yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -171,7 +171,7 @@ describe("Tier T4 — eval factory and journal integration", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), diff --git a/core/tests/eval-persist.test.ts b/core/tests/eval-persist.test.ts index 67f9a73..acea2ed 100644 --- a/core/tests/eval-persist.test.ts +++ b/core/tests/eval-persist.test.ts @@ -11,7 +11,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import { useStubFs, useEchoExec } from "@executablemd/runtime/test"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; import { unbox } from "effection"; import type { Result } from "effection"; @@ -26,7 +26,7 @@ describe("Tier T6 — persist modifier", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -45,7 +45,7 @@ describe("Tier T6 — persist modifier", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -65,7 +65,7 @@ describe("Tier T6 — persist modifier", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -84,14 +84,14 @@ describe("Tier T6 — persist modifier", () => { yield* useEchoExec(); const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), ); const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -145,7 +145,7 @@ describe("Tier T6 — persist modifier", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), diff --git a/core/tests/eval-return.test.ts b/core/tests/eval-return.test.ts index 1621350..f36058c 100644 --- a/core/tests/eval-return.test.ts +++ b/core/tests/eval-return.test.ts @@ -9,7 +9,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import { useStubFs, useEchoExec } from "@executablemd/runtime/test"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; describe("Eval block return value", () => { @@ -22,7 +22,7 @@ describe("Eval block return value", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -40,7 +40,7 @@ describe("Eval block return value", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -58,7 +58,7 @@ describe("Eval block return value", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -77,7 +77,7 @@ describe("Eval block return value", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -95,7 +95,7 @@ describe("Eval block return value", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -114,7 +114,7 @@ describe("Eval block return value", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -132,7 +132,7 @@ describe("Eval block return value", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -152,7 +152,7 @@ describe("Eval block return value", () => { // Golden run const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -162,7 +162,7 @@ describe("Eval block return value", () => { // Replay const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), diff --git a/core/tests/eval-scope.test.ts b/core/tests/eval-scope.test.ts index 09b7628..975e5e9 100644 --- a/core/tests/eval-scope.test.ts +++ b/core/tests/eval-scope.test.ts @@ -8,7 +8,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import { useStubFs, useEchoExec } from "@executablemd/runtime/test"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; describe("Tier T10 — eval-scope hierarchy", () => { @@ -21,7 +21,7 @@ describe("Tier T10 — eval-scope hierarchy", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -43,7 +43,7 @@ describe("Tier T10 — eval-scope hierarchy", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), diff --git a/core/tests/eval-timeout.test.ts b/core/tests/eval-timeout.test.ts index afe8378..80b724a 100644 --- a/core/tests/eval-timeout.test.ts +++ b/core/tests/eval-timeout.test.ts @@ -8,7 +8,7 @@ import { expect } from "@effectionx/bdd/expect"; import { parseDuration } from "../src/modifiers/timeout.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; import { useStubFs, useEchoExec } from "@executablemd/runtime/test"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; describe("Tier T7 — timeout modifier", () => { @@ -21,7 +21,7 @@ describe("Tier T7 — timeout modifier", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -62,7 +62,7 @@ describe("Tier T7 — timeout modifier", () => { // Should work with default timeout const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), diff --git a/core/tests/run-document.test.ts b/core/tests/execute.test.ts similarity index 93% rename from core/tests/run-document.test.ts rename to core/tests/execute.test.ts index df92247..ebcc049 100644 --- a/core/tests/run-document.test.ts +++ b/core/tests/execute.test.ts @@ -1,5 +1,5 @@ /** - * Integration tests for runDocument (Tiers B, D, E from spec §11). + * Integration tests for execute (Tiers B, D, E from spec §11). * * Uses API.*.around() middleware for isolation * and InMemoryStream from @executablemd/durable-streams for journaling. @@ -15,7 +15,7 @@ import { useStubFs, useFailingExec } from "@executablemd/runtime/test"; import { API } from "@executablemd/runtime"; import { forEach } from "@effectionx/stream-helpers"; import type { Operation } from "effection"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; // --------------------------------------------------------------------------- @@ -54,7 +54,7 @@ describe("Tier B — durable import", () => { yield* useStubExec(); yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -88,7 +88,7 @@ describe("Tier B — durable import", () => { // Golden run const firstResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -96,7 +96,7 @@ describe("Tier B — durable import", () => { // Replay — middleware is in scope but durable stream replays from journal const secondResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -114,14 +114,14 @@ describe("Tier B — durable import", () => { yield* useStubExec(); const firstResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), ); const secondResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -138,7 +138,7 @@ describe("Tier B — durable import", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -157,7 +157,7 @@ describe("Tier B — durable import", () => { yield* useStubExec(); yield* collect( - yield* runDocument({ + yield* execute({ docPath: "doc.md", stream, }), @@ -186,7 +186,7 @@ describe("Tier B — durable import", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -221,7 +221,7 @@ describe("Tier B — durable import", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -245,7 +245,7 @@ describe("Tier D — code execution and modifiers", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -273,7 +273,7 @@ describe("Tier D — code execution and modifiers", () => { // Golden run const firstResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -281,7 +281,7 @@ describe("Tier D — code execution and modifiers", () => { // Replay — middleware in scope, durable stream replays from journal const secondResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -298,7 +298,7 @@ describe("Tier D — code execution and modifiers", () => { yield* useFailingExec(1, "command not found"); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -317,7 +317,7 @@ describe("Tier D — code execution and modifiers", () => { yield* useStubExec(); yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -355,7 +355,7 @@ describe("Tier D — code execution and modifiers", () => { }); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -382,7 +382,7 @@ describe("Tier D — code execution and modifiers", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -407,7 +407,7 @@ describe("Tier D — code execution and modifiers", () => { // Golden const firstResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -415,7 +415,7 @@ describe("Tier D — code execution and modifiers", () => { // Replay — durable stream replays from journal const secondResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -434,7 +434,7 @@ describe("Tier D — code execution and modifiers", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -453,7 +453,7 @@ describe("Tier D — code execution and modifiers", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -473,7 +473,7 @@ describe("Tier D — code execution and modifiers", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, modifiers: { @@ -504,7 +504,7 @@ describe("Tier D — code execution and modifiers", () => { yield* useStubExec(); yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, modifiers: { @@ -524,7 +524,7 @@ describe("Tier D — code execution and modifiers", () => { // Tier E — End-to-end tests // --------------------------------------------------------------------------- -describe("runDocument", () => { +describe("execute", () => { // E1: Full document golden run it("E1: full document golden run — root + component + exec", function* () { const stream = new InMemoryStream(); @@ -558,7 +558,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -597,7 +597,7 @@ describe("runDocument", () => { // First run — golden const firstResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -605,7 +605,7 @@ describe("runDocument", () => { // Replay — durable stream replays from journal, middleware not invoked const secondResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -633,7 +633,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -652,7 +652,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -674,7 +674,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -698,7 +698,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -726,7 +726,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -770,7 +770,7 @@ describe("runDocument", () => { // Golden run to get full journal yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -790,7 +790,7 @@ describe("runDocument", () => { execCalled = false; const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream: partialStream, }), @@ -814,7 +814,7 @@ describe("runDocument", () => { // Golden run with just Header yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -827,7 +827,7 @@ describe("runDocument", () => { const newStream = new InMemoryStream(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream: newStream, }), @@ -847,7 +847,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -899,7 +899,7 @@ describe("runDocument", () => { // Golden run const firstResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -910,7 +910,7 @@ describe("runDocument", () => { // Replay — durable stream replays from journal const secondResult = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -936,7 +936,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -964,7 +964,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -992,7 +992,7 @@ describe("runDocument", () => { yield* useStubExec(); const result = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "README.md", stream, }), @@ -1017,7 +1017,7 @@ describe("Component Api dispatch — journal shape", () => { }); yield* useStubExec(); - const output = yield* collect(yield* runDocument({ docPath: "README.md", stream })); + const output = yield* collect(yield* execute({ docPath: "README.md", stream })); expect(output).toContain("note!"); const events = stream.snapshot(); @@ -1052,7 +1052,7 @@ describe("component-declared output — document workflow", () => { "components/Warn.md": "Docs line.\n\n\nWARNING_SHOWN\n\n", }); yield* useStubExec(); - const output = yield* collect(yield* runDocument({ docPath: "README.md", stream })); + const output = yield* collect(yield* execute({ docPath: "README.md", stream })); expect(output).toContain("Intro paragraph."); expect(output).toContain("WARNING_SHOWN"); expect(output).not.toContain("Docs line"); @@ -1061,7 +1061,7 @@ describe("component-declared output — document workflow", () => { it("applies to a root document the same way", function* () { const stream = new InMemoryStream(); yield* useStubFs({ "README.md": "Root docs.\n\n\nROOT_SHOWN\n\n" }); - const output = yield* collect(yield* runDocument({ docPath: "README.md", stream })); + const output = yield* collect(yield* execute({ docPath: "README.md", stream })); expect(output).toContain("ROOT_SHOWN"); expect(output).not.toContain("Root docs"); }); @@ -1070,7 +1070,7 @@ describe("component-declared output — document workflow", () => { const stream = new InMemoryStream(); yield* useStubFs({ "README.md": "Root docs.\n\n\nROOT_SHOWN\n\n" }); - const execution = yield* runDocument({ docPath: "README.md", stream }); + const execution = yield* execute({ docPath: "README.md", stream }); const chunks: string[] = []; const full = yield* forEach(function* (chunk: string) { chunks.push(chunk); @@ -1087,19 +1087,14 @@ describe("component-declared output — document workflow", () => { }); yield* useFailingExec(1, "command not found"); - const execution = yield* runDocument({ docPath: "README.md", stream }); + const execution = yield* execute({ docPath: "README.md", stream }); const chunks: string[] = []; yield* forEach(function* (chunk: string) { chunks.push(chunk); }, execution.output); - let threw = false; - try { - yield* execution; - } catch { - threw = true; - } - expect(threw).toBe(true); + const result = yield* execution; + expect(result.ok).toBe(false); expect(chunks.join("")).not.toContain("SELECTED"); }); @@ -1110,7 +1105,7 @@ describe("component-declared output — document workflow", () => { }); yield* useStubExec(); - const execution = yield* runDocument({ docPath: "README.md", stream }); + const execution = yield* execute({ docPath: "README.md", stream }); const chunks: string[] = []; yield* forEach(function* (chunk: string) { chunks.push(chunk); @@ -1123,7 +1118,7 @@ describe("component-declared output — document workflow", () => { const stream = new InMemoryStream(); yield* useStubFs({ "README.md": "Root docs.\n\n\n" }); - const execution = yield* runDocument({ docPath: "README.md", stream }); + const execution = yield* execute({ docPath: "README.md", stream }); const chunks: string[] = []; const full = yield* forEach(function* (chunk: string) { chunks.push(chunk); @@ -1137,8 +1132,8 @@ describe("component-declared output — document workflow", () => { const stream = new InMemoryStream(); yield* useStubFs({ "README.md": "Root docs.\n\n\nROOT_SHOWN\n\n" }); - const first = yield* collect(yield* runDocument({ docPath: "README.md", stream })); - const second = yield* collect(yield* runDocument({ docPath: "README.md", stream })); + const first = yield* collect(yield* execute({ docPath: "README.md", stream })); + const second = yield* collect(yield* execute({ docPath: "README.md", stream })); expect(second).toBe(first); expect(second).toContain("ROOT_SHOWN"); @@ -1151,7 +1146,7 @@ describe("component-declared output — document workflow", () => { }); yield* useFailingExec(1, "should not run"); - const result = yield* collect(yield* runDocument({ docPath: "README.md", stream })); + const result = yield* collect(yield* execute({ docPath: "README.md", stream })); expect(result).toContain("must be a direct top-level"); diff --git a/core/tests/expand-invocation.test.ts b/core/tests/expand-invocation.test.ts new file mode 100644 index 0000000..b81cadc --- /dev/null +++ b/core/tests/expand-invocation.test.ts @@ -0,0 +1,188 @@ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { expandSegments } from "../src/expand.ts"; +import { Component } from "../src/component-api.ts"; +import { scanSegments } from "../src/scanner.ts"; +import { renderSegments } from "../src/render.ts"; +import type { ComponentDefinition, EvalEnv, InvocationContext, Segment } from "../src/types.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeComponent(name: string, body: string): ComponentDefinition { + return { + kind: "markdown", + name, + path: `components/${name}.md`, + meta: {}, + inputs: {}, + bodySegments: scanSegments(body), + }; +} + +function useTestComponents(components: Record): Operation { + return Component.around( + { + // deno-lint-ignore require-yield + *importComponent([name], _next) { + const comp = components[name]; + if (!comp) { + throw new Error(`Component not found: ${name}`); + } + return comp; + }, + }, + { at: "min" }, + ); +} + +function useTestEnv(testEnv: EvalEnv): Operation { + return Component.around({ env: () => testEnv }, { at: "min" }); +} + +// --------------------------------------------------------------------------- +// expandInvocation hook +// --------------------------------------------------------------------------- + +describe("expandInvocation hook", () => { + it("falls through to normal expansion when no extension is installed", function* () { + const output = yield* scoped(function* () { + yield* useTestComponents({ Greeting: makeComponent("Greeting", "Hello world!") }); + yield* useTestEnv({ values: {} }); + const expanded = yield* expandSegments(scanSegments(""), {}, {}, new Set()); + return renderSegments(expanded); + }); + expect(output).toBe("Hello world!"); + }); + + it("lets an extension claim a name and produce segments", function* () { + const output = yield* scoped(function* () { + yield* useTestComponents({}); + yield* useTestEnv({ values: {} }); + yield* Component.around({ + *expandInvocation([invocation, ctx], next) { + if (invocation.name === "Shout") { + const inner = yield* ctx.expand(invocation.children); + return { segments: [{ type: "text", content: renderSegments(inner).toUpperCase() }] }; + } + return yield* next(invocation, ctx); + }, + }); + const expanded = yield* expandSegments( + scanSegments("hi there"), + {}, + {}, + new Set(), + ); + return renderSegments(expanded); + }); + expect(output).toBe("HI THERE"); + }); + + it("delegated names still expand normally alongside claimed names", function* () { + const output = yield* scoped(function* () { + yield* useTestComponents({ Greeting: makeComponent("Greeting", "Hello!") }); + yield* useTestEnv({ values: {} }); + yield* Component.around({ + // deno-lint-ignore require-yield + *expandInvocation([invocation, ctx], next) { + if (invocation.name === "Nothing") { + return { segments: [] }; + } + return yield* next(invocation, ctx); + }, + }); + const expanded = yield* expandSegments( + scanSegments("before"), + {}, + {}, + new Set(), + ); + return renderSegments(expanded); + }); + expect(output).toBe("beforeHello!"); + }); + + it("distinguishes handled-with-no-output from unhandled", function* () { + const seen: string[] = []; + yield* scoped(function* () { + yield* useTestComponents({ Greeting: makeComponent("Greeting", "Hello!") }); + yield* useTestEnv({ values: {} }); + yield* Component.around({ + // deno-lint-ignore require-yield + *expandInvocation([invocation, ctx], next) { + seen.push(invocation.name); + if (invocation.name === "Silent") { + return { segments: [] }; + } + return yield* next(invocation, ctx); + }, + }); + const expanded = yield* expandSegments( + scanSegments(""), + {}, + {}, + new Set(), + ); + expect(renderSegments(expanded)).toBe("Hello!"); + }); + expect(seen).toEqual(["Silent", "Greeting"]); + }); + + it("extension error segments follow the ambient raise policy", function* () { + const raised: string[] = []; + const output = yield* scoped(function* () { + yield* useTestComponents({}); + yield* useTestEnv({ values: {} }); + yield* Component.around({ + *raise([segment], next) { + raised.push(segment.message); + return yield* next(segment); + }, + }); + yield* Component.around({ + // deno-lint-ignore require-yield + *expandInvocation([invocation, ctx], next) { + if (invocation.name === "Broken") { + const error: Segment = { type: "error", message: "broken thing", source: "Broken" }; + return { segments: [error] }; + } + return yield* next(invocation, ctx); + }, + }); + const expanded = yield* expandSegments(scanSegments(""), {}, {}, new Set()); + return renderSegments(expanded); + }); + expect(raised).toEqual(["broken thing"]); + expect(output).toContain("broken thing"); + }); + + it("passes projectedEnv and parent meta/props through the context", function* () { + let observed: InvocationContext | undefined; + yield* scoped(function* () { + yield* useTestComponents({}); + yield* useTestEnv({ values: {} }); + yield* Component.around({ + // deno-lint-ignore require-yield + *expandInvocation([invocation, ctx], next) { + if (invocation.name === "Probe") { + observed = ctx; + return { segments: [] }; + } + return yield* next(invocation, ctx); + }, + }); + yield* expandSegments( + scanSegments(""), + { title: "Doc" }, + { color: "red" }, + new Set(), + ); + }); + expect(observed?.meta).toEqual({ title: "Doc" }); + expect(observed?.props).toEqual({ color: "red" }); + }); +}); diff --git a/core/tests/expression-props.test.ts b/core/tests/expression-props.test.ts index d47149e..bd98f0d 100644 --- a/core/tests/expression-props.test.ts +++ b/core/tests/expression-props.test.ts @@ -8,7 +8,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { parseExpressionValue, scanSegments } from "../src/scanner.ts"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; import * as fs from "node:fs"; @@ -169,7 +169,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -206,7 +206,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -241,7 +241,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -277,7 +277,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -307,7 +307,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -336,7 +336,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -367,7 +367,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -403,7 +403,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -433,7 +433,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -463,7 +463,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -504,7 +504,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -537,7 +537,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -569,7 +569,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -608,7 +608,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -640,14 +640,14 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], }), ); const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -687,7 +687,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -726,7 +726,7 @@ describe("Tier EP — Expression prop evaluation", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], diff --git a/core/tests/find-free-port.test.ts b/core/tests/find-free-port.test.ts index 91f0020..798bfee 100644 --- a/core/tests/find-free-port.test.ts +++ b/core/tests/find-free-port.test.ts @@ -14,7 +14,7 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import { findFreePort } from "@executablemd/runtime"; import { compileBlock } from "../src/eval-context.ts"; import { useDenoCompiler } from "../src/deno-compiler.ts"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -143,7 +143,7 @@ describe("Tier R — Eval module globals", () => { }); // --------------------------------------------------------------------------- -// Tier R — Behavioral integration tests (via runDocument) +// Tier R — Behavioral integration tests (via execute) // --------------------------------------------------------------------------- describe("Tier R — findFreePort in eval blocks", () => { @@ -166,7 +166,7 @@ describe("Tier R — findFreePort in eval blocks", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -203,7 +203,7 @@ describe("Tier R — findFreePort in eval blocks", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -238,7 +238,7 @@ describe("Tier R — findFreePort in eval blocks", () => { const stream = new InMemoryStream(); // Golden run const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -246,7 +246,7 @@ describe("Tier R — findFreePort in eval blocks", () => { // Replay — durableEval returns stored port, findFreePort not invoked const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -293,7 +293,7 @@ describe("Tier R — when in eval blocks", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -332,7 +332,7 @@ describe("Tier R — when in eval blocks", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), @@ -365,7 +365,7 @@ describe("Tier R — when in eval blocks", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, }), diff --git a/core/tests/function-components.test.ts b/core/tests/function-components.test.ts index 1c5a619..bd267a9 100644 --- a/core/tests/function-components.test.ts +++ b/core/tests/function-components.test.ts @@ -6,7 +6,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; import * as fs from "node:fs"; @@ -43,7 +43,7 @@ describe("Tier FC — Function components", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -72,7 +72,7 @@ describe("Tier FC — Function components", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -100,7 +100,7 @@ describe("Tier FC — Function components", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -128,7 +128,7 @@ describe("Tier FC — Function components", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -154,7 +154,7 @@ describe("Tier FC — Function components", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -183,7 +183,7 @@ describe("Tier FC — Function components", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -212,7 +212,7 @@ describe("Tier FC — Function components", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -236,7 +236,7 @@ describe("Tier FC — Function components", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -262,14 +262,14 @@ describe("Tier FC — Function components", () => { }); const stream = new InMemoryStream(); const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], }), ); const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], diff --git a/core/tests/named-slots.test.ts b/core/tests/named-slots.test.ts index 30c926c..1fd575f 100644 --- a/core/tests/named-slots.test.ts +++ b/core/tests/named-slots.test.ts @@ -17,7 +17,7 @@ import { scanSegments } from "../src/scanner.ts"; import { renderSegments } from "../src/render.ts"; import { parseFrontmatter } from "../src/frontmatter.ts"; import { validateProps } from "../src/validate.ts"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { Segment, ComponentDefinition, Json, CodeBlockResult } from "../src/types.ts"; @@ -694,7 +694,7 @@ describe("Tier NS-E — renderChildren interaction", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -731,7 +731,7 @@ describe("Tier NS-E — renderChildren interaction", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -769,7 +769,7 @@ describe("Tier NS-E — renderChildren interaction", () => { }); const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -897,7 +897,7 @@ describe("Tier NS-F — Edge cases", () => { }); const stream = new InMemoryStream(); yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -931,7 +931,7 @@ describe("Tier NS-F — Edge cases", () => { }); const stream = new InMemoryStream(); const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -939,7 +939,7 @@ describe("Tier NS-F — Edge cases", () => { ); // Replay — same stream, same output const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], diff --git a/core/tests/provider-integration.test.ts b/core/tests/provider-integration.test.ts index ed780c0..64c1e5f 100644 --- a/core/tests/provider-integration.test.ts +++ b/core/tests/provider-integration.test.ts @@ -14,7 +14,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; import { Sample } from "../src/sample-api.ts"; import * as fs from "node:fs"; @@ -139,7 +139,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -168,7 +168,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -211,7 +211,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -227,10 +227,10 @@ describe( }); // S4: Daemon terminated after children expand - // After runDocument completes, the daemon process is not running. - // Verified by: runDocument returns (doesn't hang), proving structured + // After execute completes, the daemon process is not running. + // Verified by: execute returns (doesn't hang), proving structured // concurrency cleaned up the daemon. - it("S4: daemon terminated after children expand — runDocument completes", function* () { + it("S4: daemon terminated after children expand — execute completes", function* () { const tmpDir = makeTempDir(); try { @@ -241,14 +241,14 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], }), ); - // runDocument returned — daemon was cleaned up by structured concurrency. + // execute returned — daemon was cleaned up by structured concurrency. // If daemon wasn't terminated, the process would hang indefinitely. expect(output).toContain("expansion-done"); } finally { @@ -293,7 +293,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -354,7 +354,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -364,7 +364,7 @@ describe( // The daemon crashed during children expansion. // The output should contain some indication of the error // (DaemonExitError) or the child output, depending on timing. - // Key property: runDocument completed (didn't hang). + // Key property: execute completed (didn't hang). expect(output).toBeTruthy(); } finally { cleanup(tmpDir); @@ -399,7 +399,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -472,7 +472,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -537,7 +537,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -602,7 +602,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -663,7 +663,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -694,7 +694,7 @@ describe( // Golden run const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs, @@ -706,7 +706,7 @@ describe( // Replay — eval blocks replay from journal, daemon spawns fresh const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs, @@ -739,7 +739,7 @@ describe( // Golden run const stream1 = new InMemoryStream(); const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream: stream1, componentDirs, @@ -766,7 +766,7 @@ describe( // Fresh stream — provider starts a new daemon, new child runs live const stream2 = new InMemoryStream(); const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream: stream2, componentDirs, @@ -805,7 +805,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -885,7 +885,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -954,7 +954,7 @@ describe( const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], diff --git a/core/tests/replay-stream.test.ts b/core/tests/replay-stream.test.ts new file mode 100644 index 0000000..2ca001d --- /dev/null +++ b/core/tests/replay-stream.test.ts @@ -0,0 +1,99 @@ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { scoped, spawn } from "effection"; +import type { Operation, Subscription } from "effection"; +import { createReplayStream } from "../src/replay-stream.ts"; + +function* drain( + subscription: Subscription, +): Operation<{ chunks: string[]; close: string }> { + const chunks: string[] = []; + let next = yield* subscription.next(); + while (!next.done) { + chunks.push(next.value); + next = yield* subscription.next(); + } + return { chunks, close: next.value }; +} + +describe("createReplayStream", () => { + it("replays chunks emitted before the subscription", function* () { + const stream = createReplayStream(); + yield* stream.send("a"); + yield* stream.send("b"); + yield* stream.close("ab"); + + const result = yield* scoped(function* () { + return yield* drain(yield* stream); + }); + expect(result).toEqual({ chunks: ["a", "b"], close: "ab" }); + }); + + it("delivers an event sent at the replay/live boundary exactly once", function* () { + const stream = createReplayStream(); + yield* stream.send("before"); + + const result = yield* scoped(function* () { + const subscription = yield* stream; + // The subscription is attached; a send after attach but before the + // first next() must arrive exactly once, after the replayed history. + yield* stream.send("boundary"); + yield* stream.close("done"); + return yield* drain(subscription); + }); + expect(result).toEqual({ chunks: ["before", "boundary"], close: "done" }); + }); + + it("gives multiple subscribers the full sequence each", function* () { + const stream = createReplayStream(); + yield* stream.send("x"); + + const [first, second] = yield* scoped(function* () { + const one = yield* stream; + const two = yield* stream; + yield* stream.send("y"); + yield* stream.close("xy"); + return [yield* drain(one), yield* drain(two)]; + }); + expect(first).toEqual({ chunks: ["x", "y"], close: "xy" }); + expect(second).toEqual({ chunks: ["x", "y"], close: "xy" }); + }); + + it("serves the close value to a subscriber that arrives after close", function* () { + const stream = createReplayStream(); + yield* stream.send("only"); + yield* stream.close("only"); + + const result = yield* scoped(function* () { + return yield* drain(yield* stream); + }); + expect(result).toEqual({ chunks: ["only"], close: "only" }); + }); + + it("live subscribers receive concurrent sends", function* () { + const stream = createReplayStream(); + + const result = yield* scoped(function* () { + const consumer = yield* spawn(function* () { + return yield* drain(yield* stream); + }); + yield* stream.send("one"); + yield* stream.send("two"); + yield* stream.close("onetwo"); + return yield* consumer; + }); + expect(result).toEqual({ chunks: ["one", "two"], close: "onetwo" }); + }); + + it("ignores events after close", function* () { + const stream = createReplayStream(); + yield* stream.close("final"); + yield* stream.send("late"); + yield* stream.close("later"); + + const result = yield* scoped(function* () { + return yield* drain(yield* stream); + }); + expect(result).toEqual({ chunks: [], close: "final" }); + }); +}); diff --git a/core/tests/sample-component.test.ts b/core/tests/sample-component.test.ts index 0fd7c23..ecd8fd5 100644 --- a/core/tests/sample-component.test.ts +++ b/core/tests/sample-component.test.ts @@ -16,7 +16,7 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import { useStubFs, useEchoExec } from "@executablemd/runtime/test"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -127,7 +127,7 @@ describe("Tier SC — Sample component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -172,7 +172,7 @@ describe("Tier SC — Sample component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -218,7 +218,7 @@ describe("Tier SC — Sample component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -250,7 +250,7 @@ describe("Tier SC — Sample component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -290,7 +290,7 @@ describe("Tier SC — Sample component", () => { // First run const stream = new InMemoryStream(); const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -301,7 +301,7 @@ describe("Tier SC — Sample component", () => { // Second run (replay) — same stream const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -339,7 +339,7 @@ describe("Tier SC — Sample component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -383,7 +383,7 @@ describe("Tier SC — Sample component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -435,7 +435,7 @@ describe("Tier SC — Sample component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -467,7 +467,7 @@ describe("Tier EO — eval output() function", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -486,7 +486,7 @@ describe("Tier EO — eval output() function", () => { // First run const output1 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -496,7 +496,7 @@ describe("Tier EO — eval output() function", () => { // Replay const output2 = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -515,7 +515,7 @@ describe("Tier EO — eval output() function", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -535,7 +535,7 @@ describe("Tier EO — eval output() function", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -553,7 +553,7 @@ describe("Tier EO — eval output() function", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -588,7 +588,7 @@ describe("Tier RC — renderChildren and render closures", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -617,7 +617,7 @@ describe("Tier RC — renderChildren and render closures", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -646,7 +646,7 @@ describe("Tier RC — renderChildren and render closures", () => { yield* useEchoExec(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: "test.md", stream, }), @@ -692,7 +692,7 @@ describe("Tier IN — Instruction component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -730,7 +730,7 @@ describe("Tier IN — Instruction component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -778,7 +778,7 @@ describe("Tier IN — Instruction component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -815,7 +815,7 @@ describe("Tier IN — Instruction component", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -882,7 +882,7 @@ describe("Tier AG — Agent component pattern", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], @@ -967,7 +967,7 @@ describe("Tier AG — Agent component pattern", () => { const stream = new InMemoryStream(); const output = yield* collect( - yield* runDocument({ + yield* execute({ docPath: path.join(tmpDir, "doc.md"), stream, componentDirs: [path.join(tmpDir, "components"), tmpDir], diff --git a/core/tests/smoke.test.ts b/core/tests/smoke.test.ts deleted file mode 100644 index d922c43..0000000 --- a/core/tests/smoke.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Smoke test — runs the full smoke-test document (smoke-test/README.md) - * through the entire pipeline using the real Node.js runtime. Verifies - * the output contains expected content from every feature: frontmatter - * interpolation, component expansion, nested components, dotted names, - * executable code blocks, silent modifier, props, Content slot, markdown - * healing, non-executable passthrough, eval blocks with shared bindings, - * binding capture (`as` and ``), persist modifier, timeout modifier, - * daemon modifier, sample modifier, - * bracket params, provider pattern, and nested providers. - */ -import { describe, it } from "@effectionx/bdd/node"; -import { expect } from "@effectionx/bdd/expect"; -import { InMemoryStream } from "@executablemd/durable-streams"; -import { runDocument } from "../src/run-document.ts"; -import { collect } from "../src/collect.ts"; - -// --------------------------------------------------------------------------- -// Smoke test -// --------------------------------------------------------------------------- - -describe("smoke test", { sanitizeOps: false, sanitizeResources: false }, () => { - it("runs the full smoke-test document and produces expected output", function* () { - const stream = new InMemoryStream(); - - const output = yield* collect( - yield* runDocument({ - docPath: "smoke-test/README.md", - stream, - componentDirs: ["smoke-test", "core/components"], - }), - ); - - // ----- Root frontmatter interpolation ----- - expect(output).toContain("# Executable MDX"); - expect(output).toContain("version **0.1.0**"); - expect(output).toContain("https://github.com/thefrontside/effectionx"); - - // ----- Section component (Content slot + meta interpolation) ----- - expect(output).toContain("§ What is Executable MDX?"); - expect(output).toContain("§ Components"); - expect(output).toContain("§ Nested Components"); - expect(output).toContain("§ Executable Code Blocks"); - expect(output).toContain("§ Props and Interpolation"); - expect(output).toContain("§ Markdown Healing"); - expect(output).toContain("§ Durability"); - expect(output).toContain("§ Smoke Test Summary"); - - // ----- Note component (props interpolation + defaults) ----- - // Default level=info - expect(output).toContain("📝 **info:** This note uses the default level (info)."); - // Overridden level=warning - expect(output).toContain("📝 **warning:** This note overrides the level to warning."); - - // ----- Feature component (nested — Feature contains Note) ----- - expect(output).toContain("**Recursive Expansion**"); - expect(output).toContain("Components expand bottom-up"); - expect(output).toContain("This note was generated inside the Feature component."); - - // ----- Dotted component name (Tips.Formatting → Tips/Formatting.md) ----- - expect(output).toContain("💡 **Formatting tip:**"); - expect(output).toContain(""); - - // ----- Executable code blocks ----- - // find count — should produce a number - expect(output).toMatch(/\d+/); - // echo block - expect(output).toContain("Hello from a durable workflow"); - - // ----- Silent exec — output suppressed ----- - // The silent block content should NOT appear in output - expect(output).not.toContain("This output is journaled but not shown in the document"); - - // ----- Non-executable code block (yaml passthrough) ----- - expect(output).toContain("# This is just a code block"); - expect(output).toContain("type: string"); - - // ----- PropDemo component ----- - expect(output).toContain('"Hey, world!"'); - - // ----- Badge component (no inputs, meta interpolation) ----- - expect(output).toContain("✓ verified"); - - // ----- Markdown healing ----- - // The unclosed bold before should be healed - // "**bold before the component" should get closed before the boundary - // Both text segments should be independently valid markdown - - // ----- Smoke test summary table ----- - expect(output).toContain("| Feature"); - expect(output).toContain("Root frontmatter"); - expect(output).toContain("Component with props"); - expect(output).toContain("Content slot"); - expect(output).toContain("Nested expansion"); - expect(output).toContain("Dotted component name"); - expect(output).toContain("exec modifier"); - expect(output).toContain("silent modifier"); - expect(output).toContain("Markdown healing"); - - // ----- Binding capture section ----- - expect(output).toContain("§ Binding Capture"); - expect(output).toContain("Capture values:"); - expect(output).toContain("component binding from Fragment"); - expect(output).toContain("| inline binding from Capture"); - expect(output).not.toContain("Hidden capture should not render inline."); - - // ----- Capture with CSS selector ----- - // select="code[lang=json]" extracts only the JSON code block value, - // ignoring surrounding prose text - expect(output).toContain('Selected JSON: ["alpha","bravo",42]'); - expect(output).not.toContain("Some prose before the data"); - - // ----- In-Process Evaluation section ----- - expect(output).toContain("§ In-Process Evaluation"); - - // Eval blocks produce no rendered output — their bindings are invisible - expect(output).not.toContain("Hello from eval"); - expect(output).not.toContain("with 3 numbers"); - - // persist eval — resource survival via spawn + when convergence - // The persist block spawns a task; the next block converges on it. - // Neither produces rendered output. - expect(output).not.toContain("serverReady"); // eval binding — no output - expect(output).toContain("kept the task alive"); // prose explains persist - - // timeout eval block — produces no output - expect(output).not.toContain("startedAt"); - - // findFreePort + eval binding interpolation - // The eval block allocates a port; the exec block uses {port} syntax. - // The output should contain "Server would start on port " - expect(output).toMatch(/Server would start on port \d+/); - - // But exec blocks in the same document still produce output - expect(output).toContain("Exec blocks are independent of eval bindings"); - - // Eval summary table entries - expect(output).toContain("eval modifier"); - expect(output).toContain("persist modifier"); - expect(output).toContain("persist resource survival"); - expect(output).toContain("timeout modifier"); - expect(output).toContain("eval + exec coexistence"); - expect(output).toContain("findFreePort VM global"); - expect(output).toContain("eval binding interpolation"); - - // ----- Background Processes section (daemon) ----- - expect(output).toContain("§ Background Processes"); - // The daemon server responds with "daemon-ok" - expect(output).toContain("daemon-ok"); - - // ----- Sample Component section ----- - expect(output).toContain("§ Sample Component"); - // Self-closing with prompt — StubProvider returns [response-from-sample-stub] - expect(output).toContain("[response-from-sample-stub]"); - // With children — children rendered then sampled - // The children text should NOT appear raw (it's consumed by the Sample component) - - // ----- Smoke test summary table — new entries ----- - expect(output).toContain("daemon modifier"); - expect(output).toContain("provider pattern"); - expect(output).toContain("per-component eval scope"); - expect(output).toContain("props in env.values"); - expect(output).toContain("Sample component"); - expect(output).toContain("output() function"); - expect(output).toContain("renderChildren() closure"); - expect(output).toContain("Instruction component"); - expect(output).toContain("composable instructions"); - expect(output).toContain("component as capture"); - expect(output).toContain("Capture directive"); - expect(output).toContain("Capture select"); - - // ----- Instruction Component section ----- - expect(output).toContain("§ Instruction Component"); - // Instruction wraps Sample — response includes system prompt text - expect(output).toContain("[response-from-instruction-stub|system:You are a helpful pirate.]"); - - // ----- Component-declared Output section ----- - expect(output).toContain("§ Component-declared Output"); - // The inside renders (its `when` binding was computed by - // preceding documentation). - expect(output).toContain("OUTPUTDEMO_SELECTED"); - // The component's documentation prose is suppressed. - expect(output).not.toContain("OUTPUTDEMO_DOC_LEAK"); - - // ----- Durability section ----- - expect(output).toContain("Run at:"); - - // ----- Journal should have events ----- - const events = stream.snapshot(); - // At minimum: root import + Section(x9) + Note(x5 total) + Feature + Badge + Formatting + PropDemo + exec blocks + eval blocks - expect(events.length).toBeGreaterThan(10); - }); - - it("replay produces identical output without re-reading files", function* () { - const stream = new InMemoryStream(); - - // Golden run - const firstOutput = yield* collect( - yield* runDocument({ - docPath: "smoke-test/README.md", - stream, - componentDirs: ["smoke-test", "core/components"], - }), - ); - - // Replay — durableRun short-circuits on the Close event in the - // journal, returning the stored result without any I/O calls. - const secondOutput = yield* collect( - yield* runDocument({ - docPath: "smoke-test/README.md", - stream, - componentDirs: ["smoke-test", "core/components"], - }), - ); - - expect(secondOutput).toBe(firstOutput); - }); -}); diff --git a/core/tests/source-position.test.ts b/core/tests/source-position.test.ts new file mode 100644 index 0000000..2629893 --- /dev/null +++ b/core/tests/source-position.test.ts @@ -0,0 +1,121 @@ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { useStubFs } from "@executablemd/runtime/test"; +import { scanSegments } from "../src/scanner.ts"; +import { Component } from "../src/component-api.ts"; +import { execute } from "../src/execute.ts"; +import { collect } from "../src/collect.ts"; +import type { ComponentInvocation, SourcePosition } from "../src/types.ts"; + +function componentsOf(segments: ReturnType): ComponentInvocation[] { + const found: ComponentInvocation[] = []; + for (const segment of segments) { + if (segment.type === "component") { + found.push(segment); + found.push(...componentsOf(segment.children)); + } + } + return found; +} + +describe("source positions", () => { + it("attaches local positions without an origin", function* () { + const [greeting] = componentsOf(scanSegments("line one\n\n")); + expect(greeting?.position).toEqual({ path: undefined, offset: 9, line: 2, column: 1 }); + }); + + it("attaches positions to nested children at original offsets", function* () { + const text = "\n text\n \n\n"; + const [outer, inner] = componentsOf(scanSegments(text)); + expect(outer?.position).toEqual({ path: undefined, offset: 0, line: 1, column: 1 }); + expect(inner?.position).toEqual({ + path: undefined, + offset: text.indexOf("", { path: "Doc.md", baseOffset: 40, baseLine: 5 }), + ); + expect(greeting?.position).toEqual({ + path: "Doc.md", + offset: 41, + line: 6, + column: 1, + }); + }); + + it("computes original-file positions past frontmatter, immune to repeated body text", function* () { + // The frontmatter contains a copy of the body's first line: a content + // search for the body would false-match inside the frontmatter, so only + // the suffix computation yields the correct base. + const doc = [ + "---", + 'title: ""', + "note: intro line", + "---", + "intro line", + "", + "", + ].join("\n"); + + const positions: SourcePosition[] = []; + const stream = new InMemoryStream(); + yield* useStubFs({ "README.md": doc, "components/Probe.md": "probed\n" }); + yield* Component.around({ + *expandInvocation([invocation, ctx], next) { + if (invocation.position) { + positions.push(invocation.position); + } + return yield* next(invocation, ctx); + }, + }); + + const output = yield* collect(yield* execute({ docPath: "README.md", stream })); + expect(output).toContain("probed"); + + const probeLine = doc.split("\n").indexOf("") + 1; + expect(positions).toEqual([ + { + path: "README.md", + offset: doc.indexOf("\n") + 1, + line: probeLine, + column: 1, + }, + ]); + }); + + it("positions imported-component invocations in the component's own file", function* () { + const readme = "\n"; + const wrapper = ["---", "title: Wrapper", "---", "before", "", ""].join("\n"); + + const positions: Array<{ name: string; position?: SourcePosition }> = []; + const stream = new InMemoryStream(); + yield* useStubFs({ + "README.md": readme, + "components/Wrapper.md": wrapper, + "components/Probe.md": "probed\n", + }); + yield* Component.around({ + *expandInvocation([invocation, ctx], next) { + positions.push({ name: invocation.name, position: invocation.position }); + return yield* next(invocation, ctx); + }, + }); + + const output = yield* collect(yield* execute({ docPath: "README.md", stream })); + expect(output).toContain("probed"); + + const probe = positions.find((p) => p.name === "Probe"); + expect(probe?.position).toEqual({ + path: "components/Wrapper.md", + offset: wrapper.indexOf(""), + line: 5, + column: 1, + }); + }); +}); diff --git a/core/tests/streaming-emission.test.ts b/core/tests/streaming-emission.test.ts index 3080c94..3da8891 100644 --- a/core/tests/streaming-emission.test.ts +++ b/core/tests/streaming-emission.test.ts @@ -2,14 +2,14 @@ * Tier SE — Streaming emission tests + Tier BC — Block ID counter (spec §9, §6.1). * * Tests per-segment emission through the document stream returned by - * runDocument, and blockId stability across per-segment expansion calls. + * execute, and blockId stability across per-segment expansion calls. */ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import { forEach } from "@effectionx/stream-helpers"; import { createBlockCounter } from "../src/expand.ts"; -import { runDocument } from "../src/run-document.ts"; +import { execute } from "../src/execute.ts"; describe("Tier BC — Block ID counter", () => { // BC1: Counter increments across calls @@ -36,7 +36,7 @@ describe("Tier SE — Streaming emission", () => { it("SE1: segments emitted in document order via stream", function* () { const chunks: string[] = []; - const execution = yield* runDocument({ + const execution = yield* execute({ docPath: "core/tests/fixtures/streaming/multi-segment.md", stream: new InMemoryStream(), }); @@ -55,7 +55,7 @@ describe("Tier SE — Streaming emission", () => { it("SE10: no empty strings in output chunks", function* () { const chunks: string[] = []; - const execution = yield* runDocument({ + const execution = yield* execute({ docPath: "core/tests/fixtures/streaming/simple.md", stream: new InMemoryStream(), }); @@ -74,7 +74,7 @@ describe("Tier SE — Streaming emission", () => { it("SE9: output() inside durable workflow reaches stream consumer", function* () { const chunks: string[] = []; - const execution = yield* runDocument({ + const execution = yield* execute({ docPath: "core/tests/fixtures/streaming/simple.md", stream: new InMemoryStream(), }); diff --git a/deno.json b/deno.json index e291aa5..6818dd9 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,5 @@ { - "workspace": ["core", "cli", "durable-streams", "runtime", "packages/code-review-agent", "site"], + "workspace": ["core", "cli", "durable-streams", "runtime", "testing", "packages/code-review-agent", "site"], "nodeModulesDir": "auto", "imports": { "@types/node": "npm:@types/node@^24.5.2", @@ -11,6 +11,8 @@ "effection/experimental": "npm:effection@4.1.0-alpha.7/experimental", "@executablemd/durable-streams": "./durable-streams/mod.ts", "@executablemd/runtime": "./runtime/mod.ts", + "@executablemd/testing": "./testing/mod.ts", + "@std/assert": "jsr:@std/assert@^1.0.17", "@executablemd/runtime/test": "./runtime/test/mod.ts", "@executablemd/code-review-agent": "./packages/code-review-agent/mod.ts", "@effectionx/context-api": "npm:@effectionx/context-api@0.6.0", @@ -37,9 +39,9 @@ "build": "deno compile --node-modules-dir=none --exclude-unused-npm --allow-all --include packages/code-review-agent --output dist/xmd cli/src/cli.ts", "gen:publish-workflow": "deno run --allow-all cli/src/cli.ts run scripts/gen-publish-workflow.md", "bump": "deno run -A scripts/bump-version.ts", - "test": "deno test --allow-all core/tests/ durable-streams/tests/ packages/code-review-agent/tests/", - "check": "deno check core/mod.ts", - "lint": "npx oxlint -c .oxlintrc.json core/src/ cli/src/ durable-streams/ runtime/ && npx oxfmt --check core/src/ cli/src/ durable-streams/ runtime/ test-support/", + "test": "deno test --allow-all core/tests/ durable-streams/tests/ testing/tests/ packages/code-review-agent/tests/", + "check": "deno check core/mod.ts testing/mod.ts", + "lint": "npx oxlint -c .oxlintrc.json core/src/ cli/src/ durable-streams/ runtime/ testing/ && npx oxfmt --check core/src/ cli/src/ durable-streams/ runtime/ testing/ test-support/", "review": "deno run --allow-all cli/src/cli.ts run .reviews/ReviewPR.md --component-dir .reviews/components --component-dir .reviews/policies --component-dir core/components -j .reviews/journal.jsonl", "review:local": "deno run --allow-all cli/src/cli.ts run .reviews/ReviewPR.local.md --component-dir .reviews/components --component-dir .reviews/policies --component-dir core/components -j .reviews/journal.local.jsonl", "analyze": "deno run --allow-all cli/src/cli.ts run .reviews/AnalyzeRepo.md --component-dir .reviews/components --component-dir .reviews/policies --component-dir core/components -j .reviews/journal.analyze.jsonl", diff --git a/deno.lock b/deno.lock index 358048b..0120a12 100644 --- a/deno.lock +++ b/deno.lock @@ -52,6 +52,7 @@ "npm:@effectionx/stream-helpers@0.8.3": "0.8.3_effection@4.1.0-alpha.7", "npm:@effectionx/test-adapter@0.7.4": "0.7.4_effection@4.1.0-alpha.7", "npm:@effectionx/timebox@0.4.3": "0.4.3_effection@4.1.0-alpha.7", + "npm:@jsr/std__assert@^1.0.17": "1.0.19", "npm:@opentelemetry/api@^1.9.0": "1.9.1", "npm:@preact/signals@^2.5.1": "2.9.3_preact@10.29.7__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.7", "npm:@preact/signals@^2.9.0": "2.9.3_preact@10.29.7__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.7", @@ -172,7 +173,10 @@ ] }, "@std/assert@1.0.19": { - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e" + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" @@ -990,6 +994,17 @@ "@jridgewell/sourcemap-codec" ] }, + "@jsr/std__assert@1.0.19": { + "integrity": "sha512-pEj6RPkGbqlgRmyKwATp4cUs6+ijxtdrv3bq8v1d2I2CEcMEyPaO8cVKro61wGRDH4cNg8Zx6haztvK/9m7gkA==", + "dependencies": [ + "@jsr/std__internal" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.19.tgz" + }, + "@jsr/std__internal@1.0.12": { + "integrity": "sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz" + }, "@microsoft/fetch-event-source@2.0.1": { "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" }, @@ -2876,6 +2891,7 @@ }, "workspace": { "dependencies": [ + "jsr:@std/assert@^1.0.17", "jsr:@std/testing@1", "npm:@durable-streams/client@~0.2.2", "npm:@effectionx/context-api@0.6.0", @@ -3022,6 +3038,20 @@ "npm:expect@30" ] } + }, + "testing": { + "dependencies": [ + "jsr:@std/assert@^1.0.17" + ], + "packageJson": { + "dependencies": [ + "npm:@effectionx/context-api@0.6.0", + "npm:@effectionx/scope-eval@0.1.3", + "npm:@effectionx/timebox@0.4.3", + "npm:@jsr/std__assert@^1.0.17", + "npm:effection@4.1.0-alpha.7" + ] + } } } } diff --git a/package.json b/package.json index 7321d6c..05b7879 100644 --- a/package.json +++ b/package.json @@ -60,14 +60,15 @@ "test:node": "tsx --tsconfig tsconfig.node.json --test durable-streams/tests/*.test.ts core/tests/frontmatter.test.ts core/tests/heal.test.ts core/tests/scanner.test.ts core/tests/eval-transform.test.ts core/tests/eval-interpolate.test.ts core/tests/document-output-api.test.ts core/tests/output-normalize.test.ts core/tests/output-terminal.test.ts", "test:bun": "bun test durable-streams/tests/ core/tests/frontmatter.test.ts core/tests/heal.test.ts core/tests/scanner.test.ts core/tests/eval-transform.test.ts core/tests/eval-interpolate.test.ts core/tests/document-output-api.test.ts core/tests/output-normalize.test.ts core/tests/output-terminal.test.ts", "test:deno": "deno task test", - "lint": "oxlint -c .oxlintrc.json core/src/ cli/src/ durable-streams/ runtime/ && oxfmt --check core/src/ cli/src/ durable-streams/ runtime/ test-support/", - "fmt": "oxfmt --write core/src/ cli/src/ durable-streams/ runtime/ test-support/ core/tests/ durable-streams/tests/ packages/code-review-agent/" + "lint": "oxlint -c .oxlintrc.json core/src/ cli/src/ durable-streams/ runtime/ testing/ && oxfmt --check core/src/ cli/src/ durable-streams/ runtime/ testing/ test-support/", + "fmt": "oxfmt --write core/src/ cli/src/ durable-streams/ runtime/ testing/ test-support/ core/tests/ durable-streams/tests/ packages/code-review-agent/" }, "workspaces": [ "core", "cli", "durable-streams", "runtime", + "testing", "packages/code-review-agent", "test-support" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecf08ef..faec0b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: '@executablemd/durable-streams': specifier: workspace:* version: link:../durable-streams + '@executablemd/testing': + specifier: workspace:* + version: link:../testing configliere: specifier: ^0.2.3 version: 0.2.4 @@ -228,6 +231,30 @@ importers: specifier: ^30.0.0 version: 30.3.0 + testing: + dependencies: + '@effectionx/context-api': + specifier: 0.6.0 + version: 0.6.0(effection@4.1.0-alpha.7) + '@effectionx/scope-eval': + specifier: 0.1.3 + version: 0.1.3(effection@4.1.0-alpha.7) + '@effectionx/timebox': + specifier: 0.4.3 + version: 0.4.3(effection@4.1.0-alpha.7) + '@executablemd/core': + specifier: workspace:* + version: link:../core + '@executablemd/durable-streams': + specifier: workspace:* + version: link:../durable-streams + '@std/assert': + specifier: npm:@jsr/std__assert@^1.0.17 + version: '@jsr/std__assert@1.0.19' + effection: + specifier: 4.1.0-alpha.7 + version: 4.1.0-alpha.7 + packages: '@babel/code-frame@7.29.0': @@ -488,6 +515,12 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jsr/std__assert@1.0.19': + resolution: {integrity: sha512-pEj6RPkGbqlgRmyKwATp4cUs6+ijxtdrv3bq8v1d2I2CEcMEyPaO8cVKro61wGRDH4cNg8Zx6haztvK/9m7gkA==, tarball: https://npm.jsr.io/~/11/@jsr/std__assert/1.0.19.tgz} + + '@jsr/std__internal@1.0.14': + resolution: {integrity: sha512-JT8b/t40WcR9q0GDwRZUooY7aXeXnFal8iidE/5TckdAFtvpVAsaJ71/Xgf1SfoChs41s6CZkuCYM8toaNgdvA==, tarball: https://npm.jsr.io/~/11/@jsr/std__internal/1.0.14.tgz} + '@microsoft/fetch-event-source@2.0.1': resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} @@ -1493,6 +1526,12 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jsr/std__assert@1.0.19': + dependencies: + '@jsr/std__internal': 1.0.14 + + '@jsr/std__internal@1.0.14': {} + '@microsoft/fetch-event-source@2.0.1': {} '@oxfmt/binding-android-arm-eabi@0.41.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ea908c..b2216e2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ packages: - cli - durable-streams - runtime + - testing - packages/code-review-agent - test-support diff --git a/runtime/apis.ts b/runtime/apis.ts index d9a6e0e..f08aed7 100644 --- a/runtime/apis.ts +++ b/runtime/apis.ts @@ -49,7 +49,7 @@ * * Middleware is **scoped** — it only affects operations within the * current Effection scope and its children. Install before calling - * `runDocument()` or `durableRun()`. + * `execute()` or `durableRun()`. * * ## Test stubs * diff --git a/runtime/test/README.md b/runtime/test/README.md index 21c8ff2..313b586 100644 --- a/runtime/test/README.md +++ b/runtime/test/README.md @@ -26,7 +26,7 @@ Installs an in-memory filesystem. - `stat(path)` reports `exists/isFile` based on whether `path` is a key - `glob()` throws with `"glob not stubbed"` -This is the right default for `runDocument()` tests that want to supply a small +This is the right default for `execute()` tests that want to supply a small virtual document tree inline. ```ts @@ -93,7 +93,7 @@ Typical combinations: These helpers are Effection middleware. They are scoped to the current operation scope and its children. -Install them before calling `runDocument()` or `durableRun()`: +Install them before calling `execute()` or `durableRun()`: ```ts it("renders from stubbed inputs", function* () { @@ -102,7 +102,7 @@ it("renders from stubbed inputs", function* () { yield* useStubFs({ "doc.md": "# Hello\n" }); yield* useEchoExec(); - const execution = yield* runDocument({ docPath: "doc.md", stream }); + const execution = yield* execute({ docPath: "doc.md", stream }); const output = yield* collect(execution); }); ``` diff --git a/runtime/test/stubs.ts b/runtime/test/stubs.ts index 6380a06..c38affa 100644 --- a/runtime/test/stubs.ts +++ b/runtime/test/stubs.ts @@ -3,7 +3,7 @@ * * These helpers install `around()` middleware on the runtime context APIs * to replace real I/O with in-memory implementations. They are scoped to - * the current Effection scope — call them before `runDocument()` or + * the current Effection scope — call them before `execute()` or * `durableRun()` in your test body. * * @example @@ -14,7 +14,7 @@ * yield* useStubFs({ "doc.md": "# Hello\n" }); * yield* useEchoExec(); * - * const execution = yield* runDocument({ docPath: "doc.md", stream }); + * const execution = yield* execute({ docPath: "doc.md", stream }); * const output = yield* execution; * }); * ``` diff --git a/smoke-test/Guide/Captures.md b/smoke-test/Guide/Captures.md new file mode 100644 index 0000000..7613615 --- /dev/null +++ b/smoke-test/Guide/Captures.md @@ -0,0 +1,39 @@ +
+ +Binding capture routes rendered output into the eval binding environment +instead of writing it at the invocation site. Component-level capture +uses `as="name"` on any component invocation; inline capture uses the +built-in `` directive; and `` extracts +specific nodes from rendered content with a CSS selector. + +
+ + +Hidden note site: + + 📝 **info:** Hidden capture should not render inline.\n"} /> + + + + + +inline binding from Capture + + + + + +Selecting from rich content: + +Some prose before the data. + +```json +["alpha","bravo",42] +``` + +More prose after. + + + + + diff --git a/smoke-test/Guide/Components.md b/smoke-test/Guide/Components.md new file mode 100644 index 0000000..166ec31 --- /dev/null +++ b/smoke-test/Guide/Components.md @@ -0,0 +1,38 @@ +
+ +A component is a markdown file with a declared interface. The component's +frontmatter defines its own metadata and the props it accepts +via `inputs`. Here's the frontmatter from the Note component tested below: + +```yaml +# components/Note.md frontmatter +emoji: 📝 +inputs: + level: info + message: + type: string + required: true +``` + +Components are invoked with JSX syntax. Props must match the declared +inputs — undeclared props are rejected, required props must be provided, +and defaults fill in for omitted optional props. Components wrap caller +content through the `` slot, the way the Section component +around this text renders its children inside a headed section. + +
+ + + + 📝 **info:** This note uses the default level (info)."} /> + + + + + 📝 **warning:** This note overrides the level to warning."} /> + + + +
SENTINEL-CONTENT
+ +
diff --git a/smoke-test/Guide/Daemons.md b/smoke-test/Guide/Daemons.md new file mode 100644 index 0000000..c1a11ea --- /dev/null +++ b/smoke-test/Guide/Daemons.md @@ -0,0 +1,36 @@ +
+ +The `daemon` modifier starts a long-running process that survives across +subsequent blocks. Combined with `when()` for readiness polling, this +implements the provider pattern: start a service, wait until it's ready, +then run against it. The test below allocates a port, starts a Node HTTP +server as a daemon, polls until it responds, and asserts the response — +when the test's scope closes, structured concurrency terminates the +daemon with no manual cleanup. + +
+ + +```js eval +const daemonPort = yield * findFreePort(); +const daemonUrl = "http://127.0.0.1:" + daemonPort; +``` +```bash daemon exec +node -e "require('http').createServer((q,s)=>{s.writeHead(200);s.end('daemon-ok')}).listen({daemonPort},'127.0.0.1')" +``` +```js eval +yield * + when( + function* () { + yield* fetch(daemonUrl + "/health").expect(); + }, + { timeout: 5000, interval: 50 }, + ); +``` + +```bash exec +curl -s http://127.0.0.1:{daemonPort} +``` + + + diff --git a/smoke-test/Guide/Durability.md b/smoke-test/Guide/Durability.md new file mode 100644 index 0000000..493b3ae --- /dev/null +++ b/smoke-test/Guide/Durability.md @@ -0,0 +1,20 @@ +
+ +Every component import and code execution is recorded in a journal. +If this document's execution crashes mid-way, re-running it replays +completed operations from the journal and continues from where it +left off — no command is re-executed, no file is re-read. The +timestamp asserted below is journaled on the first run; the harness +proves it is replayed rather than regenerated by asserting live and +replay outcomes are identical with zero appended journal events. + +
+ + + +```bash exec +echo "Run at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" +``` + + + diff --git a/smoke-test/Guide/Evaluation.md b/smoke-test/Guide/Evaluation.md new file mode 100644 index 0000000..7d3613a --- /dev/null +++ b/smoke-test/Guide/Evaluation.md @@ -0,0 +1,78 @@ +
+ +Eval blocks run JavaScript **in-process** as Effection generator operations. +Unlike `exec` blocks (which run shell commands in a subprocess), `eval` +blocks execute in the same process, sharing a binding environment across +blocks within a component. Eval blocks produce **no rendered output** — +they exist for bindings and side effects. The `persist` modifier extends +a block's resource lifetime from the block scope to the component scope, +the `timeout` modifier cancels a block that overruns its duration, the +`findFreePort` VM global allocates a free TCP port, and bare `{name}` +references interpolate eval bindings into other code blocks. + +
+ + + +```js eval +const quietValue = 1; +``` + + + + + +```js eval +const greeting = "Hello from eval"; +const numbers = [1, 2, 3]; +``` +```js eval +const message = `${greeting} with ${numbers.length} numbers`; +``` + + + + +```js persist eval +const status = { ready: false }; +yield * + spawn(function* () { + yield* sleep(10); + status.ready = true; + }); +``` +```js eval +yield * + when(function* () { + if (!status.ready) throw new Error("not ready"); + }); +const serverReady = status.ready; +``` + + + + +```js timeout=30s eval +const startedAt = Date.now(); +``` + + + + +```js eval +const port = yield * findFreePort(); +``` + + + + +```js eval +const port = yield * findFreePort(); +``` + +```bash exec +echo "Server would start on port {port}" +``` + + + diff --git a/smoke-test/Guide/Execution.md b/smoke-test/Guide/Execution.md new file mode 100644 index 0000000..7d9ca25 --- /dev/null +++ b/smoke-test/Guide/Execution.md @@ -0,0 +1,47 @@ +
+ +Code blocks with `exec` in the info string are executed as shell +commands. The output replaces the code block in the rendered document. +For example, counting the markdown files in this suite: + +```bash exec +find smoke-test -name '*.md' | wc -l | tr -d ' ' +``` + +The info string is a **middleware chain** read left-to-right. Each +word after the language is a modifier handler that wraps the next: +`exec` alone runs the command and shows stdout, `silent exec` runs and +journals the command but suppresses output, and blocks without `exec` +are passed through as regular markdown. + +
+ + + +```bash exec +echo "Hello from a durable workflow" +``` + + + + + + +```bash silent exec +echo "This output is journaled but not shown in the document" +``` + + + + + + +```yaml +# This is just a code block — not executed +inputs: + name: + type: string +``` + + + diff --git a/smoke-test/Guide/Healing.md b/smoke-test/Guide/Healing.md new file mode 100644 index 0000000..5f3579f --- /dev/null +++ b/smoke-test/Guide/Healing.md @@ -0,0 +1,16 @@ +
+ +Components and executable code blocks are **semantic boundaries**. +Markdown constructs cannot span them. Each text segment is healed +independently using remend: if bold is opened before a component but not +closed, remend closes it before expansion proceeds, so unclosed markers +cannot bleed into component output. + +
+ + +The text below opens **bold before the component + +and continues after. + + diff --git a/smoke-test/Guide/Instructions.md b/smoke-test/Guide/Instructions.md new file mode 100644 index 0000000..18dcd0e --- /dev/null +++ b/smoke-test/Guide/Instructions.md @@ -0,0 +1,19 @@ +
+ +The `` component surfaces the system prompt as visible, +composable document content. Instead of hiding the LLM's instructions +inside provider internals, authors wrap Sample calls with +`` to define what the LLM is told. The stub +provider echoes the system prompt it received, so the response proves +what the provider was told. + +
+ + + + + + + + + diff --git a/smoke-test/Guide/Interpolation.md b/smoke-test/Guide/Interpolation.md new file mode 100644 index 0000000..987d38f --- /dev/null +++ b/smoke-test/Guide/Interpolation.md @@ -0,0 +1,77 @@ +--- +title: Executable MDX +--- + +
+ +Expression props pass runtime values from eval blocks to child +components. Unlike string attributes, expression props resolve +at expansion time against the eval binding environment. JSON literals +resolve at scan time — no eval block needed — and non-string values +coerce via `String()` when interpolated in text. + +
+ + +```js eval +const dynamicGreeting = "Howdy"; +const dynamicSubject = "expression props"; +``` + + 📝 **info:** Props were successfully passed through to this component."} /> + + + + + 📝 **info:** JSON props: count={42}, verbose={true}"} /> + + + +```js eval +const itemCount = 3; +``` +The count computed above is {itemCount}. + + + +
+ +Eval bindings also resolve in **prose text**, not just code blocks. Values +computed in eval blocks flow naturally into surrounding text without +needing a template literal inside an eval block. Both `{meta.*}` / +`{props.*}` and bare `{name}` work in the same text: meta values resolve +first, then eval bindings fill in remaining references. Escaped braces +stay literal, and references with no matching binding pass through +verbatim. + +
+ + +```js eval +const textPort = 49821; +const textHost = "127.0.0.1"; +``` +The server is running at {textHost}:{textPort}. + + + + +```js eval +const textPort = 49821; +``` +The document title is {meta.title} and the text port is {textPort}. + + + + +```js eval +const textPort = 49821; +``` +Escaped braces produce literal output: \{textPort} stays as-is. + + + + +If a bare reference has no matching binding, it passes through verbatim: {undefinedBinding} is not resolved. + + diff --git a/smoke-test/Guide/NamedSlots.md b/smoke-test/Guide/NamedSlots.md new file mode 100644 index 0000000..1c2c360 --- /dev/null +++ b/smoke-test/Guide/NamedSlots.md @@ -0,0 +1,25 @@ +
+ +Components can render caller-provided content in multiple distinct +regions using named slots. The `slot` prop on child components directs +content to matching `` positions in the +component body, and children without a `slot` prop go to the default +slot. + +
+ + + + **Left column** content via named slot. + **Right column** content via named slot. + + + + + + + + + + 📝 **info:** This note is in the left column.\n | \n> 📝 **info:** This note is in the right column.\n |"} /> + diff --git a/smoke-test/Guide/NestedComponents.md b/smoke-test/Guide/NestedComponents.md new file mode 100644 index 0000000..35fdd3e --- /dev/null +++ b/smoke-test/Guide/NestedComponents.md @@ -0,0 +1,22 @@ +
+ +Components can reference other components. When a component's body +contains another component invocation, the system resolves, imports, and +expands it recursively — with cycle detection to prevent infinite loops. +Dotted names map to directory paths: `` lives at +`components/Tips/Formatting.md`. + +
+ + + + 📝 **info:** This note was generated inside the Feature component."} /> + + + + +` inside your component\nbody to mark where the caller's children appear. If your component doesn't\ninclude ``, children are silently discarded."} /> + diff --git a/smoke-test/Guide/OutputRegions.md b/smoke-test/Guide/OutputRegions.md new file mode 100644 index 0000000..7d1a801 --- /dev/null +++ b/smoke-test/Guide/OutputRegions.md @@ -0,0 +1,15 @@ +
+ +A component can declare which region of its body renders using ``. +Everything outside the region is documentation that executes (its eval blocks +run, its captures populate bindings) but never renders into the consumer. +`OutputDemo` computes a binding in documentation, then renders a `` +inside `` that depends on it. + +
+ + + + + + diff --git a/smoke-test/Guide/Overview.md b/smoke-test/Guide/Overview.md new file mode 100644 index 0000000..5551e21 --- /dev/null +++ b/smoke-test/Guide/Overview.md @@ -0,0 +1,9 @@ +
+ +Executable MDX treats markdown files as **durable workflows**. A document +can contain component invocations that expand other markdown files, and +code blocks that execute shell commands. Every I/O operation is recorded +in a journal so that execution survives crashes and replays from where +it left off. + +
diff --git a/smoke-test/Guide/Props.md b/smoke-test/Guide/Props.md new file mode 100644 index 0000000..0693a40 --- /dev/null +++ b/smoke-test/Guide/Props.md @@ -0,0 +1,18 @@ +
+ +Every component has access to two namespaces for interpolation: + +- `{meta.key}` — the component's own frontmatter values +- `{props.key}` — values passed by the caller via JSX props + +The Section component's frontmatter defines `emoji: §` which it +prepends to each title via `{meta.emoji}`. The Note component +uses `{meta.emoji}` for its 📝 prefix and `{props.message}` for +the caller-provided text. + +
+ + + + 📝 **info:** Props were successfully passed through to this component."} /> + diff --git a/smoke-test/Guide/Sampling.md b/smoke-test/Guide/Sampling.md new file mode 100644 index 0000000..e5fef82 --- /dev/null +++ b/smoke-test/Guide/Sampling.md @@ -0,0 +1,23 @@ +
+ +The `` component captures its children's rendered output (or +accepts a `prompt` prop) and routes it through the Sample Api for LLM +processing. The stub provider echoes the content it received, so each +response proves which content reached the provider — and, in children +mode, that the child content was consumed rather than rendered directly. + +
+ + + + + + + + + + +This is child content to be processed. + + + diff --git a/smoke-test/Guide/Summary.md b/smoke-test/Guide/Summary.md new file mode 100644 index 0000000..0b0a0b9 --- /dev/null +++ b/smoke-test/Guide/Summary.md @@ -0,0 +1,58 @@ +
+ +This document exercises every feature of the system: + +```bash exec +cat <<'TABLE' +| Feature | Exercised by | +|---------------------------|-----------------------------------------| +| Root frontmatter | Title and version in opening paragraph | +| Component with props |
, | +| Required props | (message is required) | +| Default props | uses level=info by default | +| Content slot |
wraps children via | +| Nested expansion | Section > Feature > Note (3 levels) | +| Dotted component name | | +| exec modifier | Multiple bash exec blocks | +| silent modifier | bash silent exec block | +| Non-executable code | yaml block (passthrough) | +| Markdown healing | Unclosed bold before | +| No-inputs component | accepts zero props | +| meta interpolation | {meta.emoji} in Section and Note | +| props interpolation | {props.title}, {props.message}, etc. | +| Props passthrough | | +| Expression props | | +| component as capture | ... | +| Capture directive | ... | +| Capture select | ... | +| Durability | Timestamp stable across reruns | +| eval modifier | js eval blocks with shared bindings | +| persist modifier | js persist eval block, resource lifetime| +| persist resource survival | spawn in persist eval + when() converge | +| timeout modifier | js timeout=30s eval block | +| eval + exec coexistence | Both modifier types in same document | +| findFreePort VM global | yield* findFreePort() in eval block | +| eval binding interpolation| {port} in exec block from eval binding | +| daemon modifier | bash daemon exec starts background proc | +| daemon + when readiness | Daemon server polled until ready | +| provider pattern | StubProvider installs Sample middleware | +| per-component eval scope | Each provider gets isolated middleware | +| props in env.values | model prop available in eval blocks | +| Sample component | , with children | +| output() function | Sample component calls output() | +| renderChildren() closure | Sample component captures children | +| Named slots | with slot="left"/slot="right" | +| Fragment passthrough | wraps raw text | +| Instruction component | wraps Sample calls | +| composable instructions | Instructions enrich SampleContext.system | +| Text interpolation | {textHost}:{textPort} in prose text | +| Text + meta coexistence | {meta.title} and {textPort} in same text| +| Escaped text bindings | \{textPort} produces literal braces | +| Verbatim unresolved | {undefinedBinding} left as-is | +| Non-string text coercion | {itemCount} coerced via String() | +TABLE +``` + +If you can read this table, every feature worked. + +
diff --git a/smoke-test/README.md b/smoke-test/README.md index 88dc3b3..f73ac88 100644 --- a/smoke-test/README.md +++ b/smoke-test/README.md @@ -6,515 +6,51 @@ repo: https://github.com/thefrontside/effectionx # {meta.title} -This document is both a guide and a smoke test. Every feature described -here is exercised by the document itself — if it renders correctly, -the system works. This is version **{meta.version}** of {meta.title}, +This document is both a guide and a smoke test. Each chapter explains a +feature in prose and carries atomic tests that run the demonstrated +scenario end to end — setup, action, capture, and assertion live inside +each test, so regular execution renders the guide while `xmd test` runs +every scenario. This is version **{meta.version}** of {meta.title}, built from the source at [{meta.repo}]({meta.repo}). -
+ +# {meta.title} + + -Executable MDX treats markdown files as **durable workflows**. A document -can contain component invocations that expand other markdown files, and -code blocks that execute shell commands. Every I/O operation is recorded -in a journal so that execution survives crashes and replays from where -it left off. + +This is version **{meta.version}** of {meta.title}, built from the source at [{meta.repo}]({meta.repo}). + + -
+ -
+ -A component is a markdown file with a declared interface. The component's -frontmatter defines its own metadata and the props it accepts -via `inputs`. Here's the frontmatter from the Note component used below: + -```yaml -# components/Note.md frontmatter -emoji: 📝 -inputs: - level: info - message: - type: string - required: true -``` + -Components are invoked with JSX syntax. Props must match the declared -inputs — undeclared props are rejected, required props must be provided, -and defaults fill in for omitted optional props. + - + - + -Components can wrap content using the `` slot. The Section -component wrapping this text works exactly that way — it receives a -title prop and renders its children inside a headed section. + -
+ -
+ -Components can reference other components. When a component's body -contains another component invocation, the system resolves, imports, and -expands it recursively — with cycle detection to prevent infinite loops. + - + -Dotted names map to directory paths. The component below lives at -`components/Tips/Formatting.md`: + - + -
+ -
- -Code blocks with `exec` in the info string are executed as shell -commands. The output replaces the code block in the rendered document. - -How many markdown files are in the smoke-test directory? - -```bash exec -find smoke-test -name '*.md' | wc -l | tr -d ' ' -``` - -The info string is a **middleware chain** read left-to-right. Each -word after the language is a modifier handler that wraps the next. - -`exec` alone — runs the command, shows stdout: - -```bash exec -echo "Hello from a durable workflow" -``` - -`silent exec` — runs and journals the command, but suppresses -output. Useful for setup steps: - -```bash silent exec -echo "This output is journaled but not shown in the document" -``` - -Non-executable code blocks are passed through as regular markdown. -This block has no `exec` modifier, so it's just syntax-highlighted text: - -```yaml -# This is just a code block — not executed -inputs: - name: - type: string -``` - -
- -
- -Every component has access to two namespaces for interpolation: - -- `{meta.key}` — the component's own frontmatter values -- `{props.key}` — values passed by the caller via JSX props - -The Section component's frontmatter defines `emoji: §` which it -prepends to each title via `{meta.emoji}`. The Note component -uses `{meta.emoji}` for its 📝 prefix and `{props.message}` for -the caller-provided text. - - - -
- -
- -Expression props pass runtime values from eval blocks to child -components. Unlike string attributes, expression props resolve -at expansion time against the eval binding environment. - -```js eval -const dynamicGreeting = "Howdy"; -const dynamicSubject = "expression props"; -const itemCount = 3; -``` - -The values computed above flow into PropDemo via expression props: - - - -JSON literals resolve at scan time — no eval block needed: - - - -
- -
- -Binding capture routes rendered output into the eval binding environment -instead of writing it at the invocation site. - -Component-level capture uses `as="name"`: - -component binding from Fragment - -Inline capture uses the built-in `` directive: - -inline binding from Capture - - -Captured bindings are available to later executable blocks: - -```bash exec -echo "Capture values: {capturedFromComponent} | {capturedInline}" -``` - -Capture with CSS selector extracts specific content from rendered output: - - -Some prose before the data. - -```json -["alpha","bravo",42] -``` - -More prose after. - - -```bash exec -printf 'Selected JSON: %s\n' '{capturedJson}' -``` - -This Note is captured but intentionally not rendered inline: - - - -
- -
- -Components and executable code blocks are **semantic boundaries**. -Markdown constructs cannot span them. Each text segment is healed -independently using remend. - -For example, if bold is opened before a component but not closed, -remend closes it before expansion proceeds. This prevents unclosed -markers from bleeding into component output. - -The text below opens \*\*bold before the component - -and continues after. Each segment is independently valid markdown. - -
- -
- -Eval blocks run JavaScript **in-process** as Effection generator operations. -Unlike `exec` blocks (which run shell commands in a subprocess), `eval` -blocks execute in the same process, sharing a binding environment across -blocks within a component. - -Bindings declared in one eval block are available in subsequent blocks: - -```js eval -const greeting = "Hello from eval"; -const numbers = [1, 2, 3]; -``` - -The bindings from the previous block are available here: - -```js eval -const message = `${greeting} with ${numbers.length} numbers`; -``` - -Eval blocks produce **no rendered output** — they exist for bindings -and side effects. The output from eval blocks is empty, so nothing -appears between this text and the next section. - -The `persist` modifier extends a block's resource lifetime from the -block scope to the component scope. Without `persist`, spawned tasks -and resources are torn down when the eval block completes. With it, -they survive for all subsequent blocks in the component. - -The block below spawns a background task that sets `status.ready` -after a short delay. Because it uses `persist`, the task stays alive: - -```js persist eval -const status = { ready: false }; -yield * - spawn(function* () { - yield* sleep(10); - status.ready = true; - }); -``` - -The next block converges on the spawned task using `when()`. This -only works because `persist` kept the task alive across the block -boundary — without it, the task would have been torn down: - -```js eval -yield * - when(function* () { - if (!status.ready) throw new Error("not ready"); - }); -const serverReady = status.ready; -``` - -The `timeout` modifier cancels the block if it does not complete within -the specified duration. Accepted units: `ms`, `s`, `m`. If the block -times out, an error is recorded in the output and execution halts. - -```js timeout=30s eval -const startedAt = Date.now(); -``` - -The `findFreePort` VM global finds an available TCP port using the OS: - -```js eval -const port = yield * findFreePort(); -``` - -Eval binding interpolation substitutes bare `{name}` references in code -block content with values from the eval binding environment. The port -allocated above flows into subsequent blocks via `{port}`: - -```bash exec -echo "Server would start on port {port}" -``` - -Eval and exec blocks coexist independently in the same document: - -```bash exec -echo "Exec blocks are independent of eval bindings" -``` - -
- -
- -Eval bindings also resolve in **prose text**, not just code blocks. Values -computed in eval blocks flow naturally into surrounding text without -needing a template literal inside an eval block. - -```js eval -const textPort = 49821; -const textHost = "127.0.0.1"; -``` - -The server is running at {textHost}:{textPort}. - -Both `{meta.*}` / `{props.*}` and bare `{name}` work in the same text. -Meta values resolve first, then eval bindings fill in remaining references. -The document title is {meta.title} and the text port is {textPort}. - -Escaped braces produce literal output: \{textPort} stays as-is. - -If a bare reference has no matching binding, it passes through verbatim: -{undefinedBinding} is not resolved. - -Non-string values are coerced via `String()`. The count from the -Expression Props section is {itemCount}. - -
- -
- -The `daemon` modifier starts a long-running process that survives across -subsequent blocks. Combined with `when()` for readiness polling, this -implements the provider pattern: start a service, wait until it's ready, -then run children against it. - -The eval block below allocates a port, the daemon block starts a Node -HTTP server on it, and the readiness block polls until the server -responds: - -```js eval -const daemonPort = yield * findFreePort(); -const daemonUrl = "http://127.0.0.1:" + daemonPort; -``` - -```bash daemon exec -node -e "require('http').createServer((q,s)=>{s.writeHead(200);s.end('daemon-ok')}).listen({daemonPort},'127.0.0.1')" -``` - -```js eval -yield * - when( - function* () { - yield* fetch(daemonUrl + "/health").expect(); - }, - { timeout: 5000, interval: 50 }, - ); -``` - -The daemon is alive — let's verify by hitting it: - -```bash exec -curl -s http://127.0.0.1:{daemonPort} -``` - -When this section ends, the daemon process is terminated by structured -concurrency — no manual cleanup needed. - -
- -
- -The `` component captures its children's rendered output (or -accepts a `prompt` prop) and routes it through the Sample Api for LLM -processing. It uses `output()` to produce rendered output and -`renderChildren()` to capture children. - -Self-closing mode — prompt sent directly to the provider: - - - - - -With children — children are rendered first, then sampled: - - -This is child content to be processed. - - - - -
- -
- -Components can render caller-provided content in multiple distinct -regions using named slots. The `slot` prop on child components directs -content to matching `` positions in the -component body. - - - - **Left column** content via named slot. - - - **Right column** content via named slot. - - - -Named slots compose with the existing content slot. Children without -a `slot` prop go to the default slot: - - - - - - -
- -
- -The `` component surfaces the system prompt as visible, -composable document content. Instead of hiding the LLM's instructions -inside provider internals, authors wrap Sample calls with -`` to define what the LLM is told. - -Without an instruction, the provider uses a hardcoded default system -prompt. With ``, the author's text replaces that default: - - - - - - - - - -Instructions accumulate through nesting. Agent components install -instruction middleware via `persist eval` blocks that enrich the -`SampleContext.system` field. When present, the system prompt is -passed through to the provider. - -
- -
- -Every component import and code execution is recorded in a journal. -If this document's execution crashes mid-way, re-running it replays -completed operations from the journal and continues from where it -left off — no command is re-executed, no file is re-read. - -```bash exec -echo "Run at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" -``` - -Run this document twice. The timestamp above will be the same both -times — it was journaled on the first run and replayed on the second. - -If a component file changes between runs, the replay guard detects -the stale content hash and halts replay, forcing a fresh execution. - -
- -
- -A component can declare which region of its body renders using ``. -Everything outside the region is documentation that executes (its eval blocks -run, its captures populate bindings) but never renders into the consumer. -`OutputDemo` computes a binding in documentation, then renders a `` -inside `` that depends on it. - - - -
- -
- -This document exercises every feature of the system: - -```bash exec -cat <<'EOF' -| Feature | Exercised by | -|---------------------------|-----------------------------------------| -| Root frontmatter | Title and version in opening paragraph | -| Component with props |
, | -| Required props | (message is required) | -| Default props | uses level=info by default | -| Content slot |
wraps children via | -| Nested expansion | Section > Feature > Note (3 levels) | -| Dotted component name | | -| exec modifier | Multiple bash exec blocks | -| silent modifier | bash silent exec block | -| Non-executable code | yaml block (passthrough) | -| Markdown healing | Unclosed bold before | -| No-inputs component | accepts zero props | -| meta interpolation | {meta.emoji} in Section and Note | -| props interpolation | {props.title}, {props.message}, etc. | -| Props passthrough | | -| Expression props | | -| component as capture | ... | -| Capture directive | ... | -| Capture select | ... | -| Durability | Timestamp stable across reruns | -| eval modifier | js eval blocks with shared bindings | -| persist modifier | js persist eval block, resource lifetime| -| persist resource survival | spawn in persist eval + when() converge | -| timeout modifier | js timeout=30s eval block | -| eval + exec coexistence | Both modifier types in same document | -| findFreePort VM global | yield* findFreePort() in eval block | -| eval binding interpolation| {port} in exec block from eval binding | -| daemon modifier | bash daemon exec starts background proc | -| daemon + when readiness | Daemon server polled until ready | -| provider pattern | StubProvider installs Sample middleware | -| per-component eval scope | Each provider gets isolated middleware | -| props in env.values | model prop available in eval blocks | -| Sample component | , with children | -| output() function | Sample component calls output() | -| renderChildren() closure | Sample component captures children | -| Named slots | with slot="left"/slot="right" | -| Fragment passthrough | wraps raw text | -| Instruction component | wraps Sample calls | -| composable instructions | Instructions enrich SampleContext.system | -| Text interpolation | {textHost}:{textPort} in prose text | -| Text + meta coexistence | {meta.title} and {textPort} in same text| -| Escaped text bindings | \{textPort} produces literal braces | -| Verbatim unresolved | {undefinedBinding} left as-is | -| Non-string text coercion | {itemCount} coerced via String() | -EOF -``` - -If you can read this table, every feature worked. - -
+ diff --git a/smoke-test/StubProvider.md b/smoke-test/StubProvider.md index 687aa4d..d71b11e 100644 --- a/smoke-test/StubProvider.md +++ b/smoke-test/StubProvider.md @@ -15,7 +15,7 @@ yield* Sample.around({ return yield* next(context); } const sys = context.system ? '|system:' + context.system : ''; - return '[response-from-' + model + sys + ']'; + return '[response-from-' + model + sys + '|content:' + context.content + ']'; }, }, { at: 'min' }); ``` diff --git a/specs/decisions.md b/specs/decisions.md index 2acf57c..9f8dcaf 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -170,13 +170,13 @@ experimental implementation detail. Its API may change. ### Decision Keep stream protocol integration in the execution boundary -(`src/run-document.ts`, `src/eval-handler.ts`, and terminal effect handlers). +(`src/execute.ts`, `src/eval-handler.ts`, and terminal effect handlers). All other modules (scanner, frontmatter, expand, interpolate, validate, render) have zero dependency on the stream package. The expansion engine reaches import and modifier execution through the contextual Component Api (DEC-012) — it doesn't know about durable -effects. The document's providers, installed by `run-document.ts`, are +effects. The document's providers, installed by `execute.ts`, are the only place expansion meets the stream protocol. --- diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 5273e32..326342e 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -106,7 +106,7 @@ The journal records: ### 1.2 Workspace-relative paths All paths stored in a diagnostic trace are **relative to the workspace root** -(the current working directory when `runDocument` is called). This +(the current working directory when `execute` is called). This makes traces easier to compare and avoids leaking absolute local paths. Runtime operations (`readTextFile`, `stat`, `exec`, `glob`) all resolve @@ -451,7 +451,7 @@ registry.set("timeout", timeoutFactory); registry.set("daemon", daemonFactory); ``` -Custom factories can be provided via `RunDocumentOptions.modifiers`. +Custom factories can be provided via `ExecuteOptions.modifiers`. #### Built-in terminal handlers @@ -643,10 +643,10 @@ the block if it overruns. The silent handler discards the output. #### Overriding per-scope Because factories are stored in a registry that can be extended, -custom modifiers can be provided via `RunDocumentOptions`: +custom modifiers can be provided via `ExecuteOptions`: ```typescript -yield* runDocument({ +yield* execute({ docPath: "README.md", stream, runtime, @@ -1134,7 +1134,7 @@ lifetime matches the document's expansion. Resources spawned by `persist` blocks are retained in this scope until expansion completes. The current eval scope is read contextually via the `evalScope` value (§5.5). -The eval scope is created in `runDocument()` (§8.1) **before** +The eval scope is created in `execute()` (§8.1) **before** `durableRun` via `resource(useEvalScope())`. This is critical: `evalScope.eval()` sends to a channel whose processor must be reachable by the Effection scheduler — this only works when both sender @@ -1850,7 +1850,7 @@ returning without calling it. never observe each other's state. Caller-projected content, the current code block, the persistent flag, and function-component content all rely on this. -- `runDocument` installs the document's `importComponent` / +- `execute` installs the document's `importComponent` / `applyModifiers` / root `evalScope` providers before starting the durable workflow, so the whole run inherits them; the journal shape of import, exec, and eval effects is unchanged by contextual @@ -2517,7 +2517,7 @@ A **provider component** is a regular markdown component whose body follows a structured pattern that manages background process lifecycle for its subtree. It composes `eval` + `daemon` + `eval` (readiness) + `eval` (middleware install) + `` into a reusable -component — no framework-level configuration, no `RunDocumentOptions` +component — no framework-level configuration, no `ExecuteOptions` changes. #### Structure @@ -2936,9 +2936,9 @@ only when output is emitted, not what executes, so replay is deterministic. ## 7. Entry point -### 8.1 `runDocument` +### 8.1 `execute` -`runDocument(options)` executes a markdown document as a durable +`execute(options)` executes a markdown document as a durable workflow and returns a `DocumentExecution` handle. Options: - `docPath` — path to the root markdown document @@ -2948,23 +2948,31 @@ workflow and returns a `DocumentExecution` handle. Options: - `modifiers?` — custom modifier factories registered alongside the built-ins (`exec`, `silent`, `eval`, `persist`, `timeout`, `daemon`) -`DocumentExecution` is an `Operation`: `yield* execution` -completes with the document's full output. Its `output` property is a -`Stream` of the chunks emitted during execution -(per-segment for streaming roots, one chunk for buffered `` -roots — §5.4); the stream closes with the full output as its close -value. +`DocumentExecution` is an `Operation>`: `yield* execution` +completes with `Ok(output)` on success and `Err(error)` on document, +infrastructure, or policy failure. Once `execute` has returned a handle, +completion never throws — every later failure closes the output stream +(with the complete or partial rendered output) and resolves `Err`. A +failure before a handle can be created may still throw. Its `output` +property is a replay-safe `Stream` of the chunks emitted +during execution (per-segment for streaming roots, one chunk for buffered +`` roots — §5.4); late and repeated subscribers receive the full +sequence, and the stream closes with the full (or partial) output as its +close value. Execution runs in its own scope. Before the durable workflow starts, -`runDocument` installs the document's scope-local runtime providers — +`execute` installs the document's scope-local runtime providers — the platform compiler, the Component providers for import, modifier execution, and the root eval scope (§5.5), and the output→stream bridge — so nothing leaks onto the caller's scope and the whole run inherits them contextually. -On successful completion, `yield* execution` returns the full output -and the `output` stream closes with it. On failure, the stream closes -and `yield* execution` throws the workflow error. +`execute` is delivered through the `Execution` context Api. The default +provider runs the document; extensions decorate the execution lifecycle +with `Execution.around({ execute })` middleware — observing options, +wrapping the returned handle, or mapping its completion `Result` — without +introducing another execution function. Core itself has no knowledge of +any particular extension. ### 8.2 Usage from standalone code @@ -2973,13 +2981,17 @@ import { run } from "effection"; import { InMemoryStream } from "@executablemd/durable-streams"; await run(function* () { - const execution = yield* runDocument({ + const execution = yield* execute({ docPath: "./README.md", stream: new InMemoryStream(), }); - const output = yield* execution; - console.log(output); + const result = yield* execution; + if (result.ok) { + console.log(result.value); + } else { + console.error(result.error.message); + } }); ``` @@ -3050,7 +3062,7 @@ Three concerns, three mechanisms: | Concern | Mechanism | Where | |---|---|---| | **Transformation** | Middleware (`scope.around`) | `output/normalize.ts`, `output/terminal.ts` | -| **Delivery** | Channel (`createChannel`, internal to `runDocument`) | `run-document.ts` | +| **Delivery** | Channel (`createChannel`, internal to `execute`) | `execute.ts` | | **Consumption** | Stream (`forEach` on `execution.output`) | Caller (`cli.ts`, tests) | Middleware only intercepts and transforms. Buffering and streaming are not @@ -3141,20 +3153,20 @@ export function* useTerminalOutput(): Operation { **File:** `cli/src/cli.ts` (separate `cli` workspace package) The CLI installs output middleware (transforms only — no channel wiring -needed), calls `runDocument` to get a `DocumentExecution`, consumes +needed), calls `execute` to get a `DocumentExecution`, consumes `execution.output` with `forEach` for streaming, and can `yield*` the execution directly to get the full output or catch errors. ```typescript // cli/src/cli.ts import { forEach } from "@effectionx/stream-helpers"; -import { runDocument, useNormalizedOutput, useTerminalOutput } from "@executablemd/core"; +import { execute, useNormalizedOutput, useTerminalOutput } from "@executablemd/core"; function* run(/* ... config params ... */) { if (!raw) yield* useNormalizedOutput(); if (process.stdout.isTTY && !raw) yield* useTerminalOutput(); - const execution = yield* runDocument({ docPath, stream, runtime, ... }); + const execution = yield* execute({ docPath, stream, runtime, ... }); const fullOutput = yield* forEach(function* (chunk: string) { if (process.stdout.isTTY) { @@ -3176,7 +3188,7 @@ function* run(/* ... config params ... */) { output(text) → normalize (middleware, caller-installed) → terminal format (middleware, caller-installed) - → channel.send(text) (internal to runDocument) + → channel.send(text) (internal to execute) → execution.output stream (caller's forEach/collect) ``` @@ -3187,7 +3199,7 @@ User sees cleaned, colorized text streaming segment-by-segment. ``` output(text) → normalize (middleware, caller-installed) - → channel.send(text) (internal to runDocument) + → channel.send(text) (internal to execute) → execution.output stream (caller's forEach/collect) → fullOutput written to stdout at end ``` @@ -3198,7 +3210,7 @@ User gets cleaned raw markdown dumped at end. ``` output(text) - → channel.send(text) (internal to runDocument, no transformation) + → channel.send(text) (internal to execute, no transformation) → execution.output stream (caller's forEach/collect) ``` @@ -3242,16 +3254,17 @@ All middleware and side effects triggered by `output()` (normalization, formatting, channel send) execute on the ephemeral side. No durable state capture occurs in the output pipeline. -The entire workflow runs in a `spawn()` inside `runDocument`. The channel +The entire workflow runs in a `spawn()` inside `execute`. The channel and all execution state (runtime API middleware and eval scope, DocumentOutput→channel bridge) share this spawned scope. The consumer (`forEach`/`collect` on `execution.output`) runs in the **caller's** scope. This cross-boundary communication is safe because scope teardown of the spawned task cancels the producer and closes the channel, which terminates the consumer's forEach loop. The `withResolvers` completion -signal also lives in the spawned scope — `resolve()` and `reject()` are -called from inside the spawn, and the resulting operation is returned to -the caller as part of the `DocumentExecution` handle. +signal also lives in the spawned scope — `resolve(Ok(...))` and +`resolve(Err(...))` are called from inside the spawn, and the resulting +operation is returned to the caller as part of the `DocumentExecution` +handle. ### 9.10 Known issues @@ -3287,7 +3300,7 @@ core/src/ mod.ts Barrel export normalize.ts Whitespace normalization middleware terminal.ts Terminal ANSI formatting middleware - run-document.ts Document runner (owns channel, returns stream) + execute.ts Document runner (owns channel, returns stream) cli/src/ cli.ts CLI entrypoint with forEach stream consumption @@ -3767,7 +3780,7 @@ visible warning blocks, collect into a separate error report). | S1 | Full provider golden run | eval → daemon → when → children → cleanup | | S2 | Port flows from eval to daemon | `{port}` in daemon content matches `findFreePort()` result | | S3 | Children can call sample after daemon ready | `sample` calls in children reach daemon endpoint | -| S4 | Daemon terminated after children expand | After `runDocument` completes, process not running | +| S4 | Daemon terminated after children expand | After `execute` completes, process not running | | S5 | Provider crash during `when` | Daemon exits before ready → `when` fails → `ErrorSegment` | | S6 | Provider crash during children | Daemon exits mid-child-expansion → error propagated | | S7 | Nested providers | Outer + inner provider → both start, inner tears down first | @@ -3821,7 +3834,7 @@ visible warning blocks, collect into a separate error report). | OA7 | Channel close ends consumer | `channel.close()` causes `forEach` to complete | | OA8 | Multiple middleware compose | Normalize → terminal → channel: all three run in order | | OA9 | `ephemeral()` wrapper | `output()` inside durable context produces no journal entry | -| OA10 | runDocument workflow error surfaces through execution | `runDocument` workflow error → `reject(error)` → `yield* execution` throws | +| OA10 | execute workflow error surfaces through execution | `execute` workflow error → completion resolves `Err(error)` — `yield* execution` returns the `Result`, never throws | ### Tier WN — Whitespace normalization @@ -3941,7 +3954,7 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 41 | `findFreePort` is a standalone VM global using `node:net` | Port allocation is platform I/O; the function uses Effection's `once` + `race` for event handling and `try/finally` for guaranteed cleanup; exposed in the eval sandbox alongside other Effection globals | | 42 | `findFreePort` result journaled with its eval block | The port number is a scalar export; no separate journal-entry type is needed | | 43 | `when` (from `@effectionx/converge`) is the polling VM global | `when` is the exported name from the package; the sandbox already contains it; no rename or addition needed | -| 44 | Provider lifecycle expressed as a component, not a `RunDocumentOptions` field | Scope boundary is visible in the document tree; composable — multiple providers nest naturally via structured concurrency; no framework-level lifecycle hooks required | +| 44 | Provider lifecycle expressed as a component, not an `ExecuteOptions` field | Scope boundary is visible in the document tree; composable — multiple providers nest naturally via structured concurrency; no framework-level lifecycle hooks required | | 45 | Readiness check is a separate `eval` block, not internal to `daemon` | Auditable — strategy visible in the document; replaceable — different daemons have different readiness signals; composable with `when`'s configurable backoff | | 46 | Sample middleware reads `baseUrl` from `env.values` | Avoids a dedicated inference server context key; the binding environment is already the shared state carrier for within-component coordination; scope-correct because a fresh environment is provided per component expansion | | 47 | Each component gets a fresh `EvalEnv` | The component's environment is installed as a scope-local `env` provider around body expansion, so eval blocks within a component share bindings but don't leak into parent or sibling components; critical for provider isolation | @@ -3953,7 +3966,7 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 53 | Sample component calls `Sample.operations.sample()` directly | The enclosing eval operation journals the complete block result | | 54 | Sample component props default to empty string, not undefined | `validateProps` omits optional props with no default from `env.values`, causing `ReferenceError` in eval blocks; empty-string defaults ensure the variables exist; `model \|\| undefined` converts empty to undefined for routing semantics | | 55 | `daemon()` uses `shell: true` | Matches `bash exec` block semantics — the same command string passed to `bash -c` is passed to the shell; handles shell expansions and PATH lookups correctly | -| 56 | Provider installs its own middleware, not a global `useLlamafileSample()` | A single global handler installed before `runDocument()` would execute in the outer scope at call time, where the binding environment has no `baseUrl`; middleware must close over `baseUrl` and `model` at the moment the provider becomes active | +| 56 | Provider installs its own middleware, not a global `useLlamafileSample()` | A single global handler installed before `execute()` would execute in the outer scope at call time, where the binding environment has no `baseUrl`; middleware must close over `baseUrl` and `model` at the moment the provider becomes active | | 57 | Routing key is `model`, not a separate `name` prop | Model identity is the natural key — it unifies "which server to route to" with "which model to request"; a separate `name` prop would require keeping two values in sync with no added expressiveness | | 58 | `context.model === undefined` routes to innermost provider | Omitting a model is the common case for single-provider documents; innermost-wins matches how middleware chains work — handlers installed later sit higher in the chain and are traversed first | | 59 | `callLlamafile()` is a standard import in generated eval modules | Provider components are markdown files — eval blocks are compiled into `data:` URI modules that import executable.md globals from `@executablemd/core`; functions like `callLlamafile`, `callOllama`, `callAnthropic`, `Sample`, `findFreePort`, and `useContent` are available via this import | @@ -3970,7 +3983,7 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 70 | `output()` wrapped in `ephemeral()` | Output emission is a non-durable side effect; journal records durable effects only; output text is derived from journaled expansion results; all middleware/side effects execute on the ephemeral side | | 71 | Middleware installation order: normalize outer, terminal inner, channel innermost | `scope.around` later-installed handlers wrap earlier ones; execution flows outer → inner: normalize → terminal → channel; install order is reverse of execution order; must be documented to prevent reordering | | 72 | `channel.send()` must be `yield*`'d | Ensures backpressure and cancellation safety — no text "in flight" when scope tears down; without `yield*`, buffering issues or silent cancellation may occur | -| 73 | `DocumentExecution` with `withResolvers` | Execution is both an `Operation` (`yield*` for result) and has `.output` stream for chunks; errors surface through `yield* execution` via `withResolvers` `reject()`; eliminates `Result` / `unbox` in consumer code | +| 73 | `DocumentExecution` with `withResolvers` | Execution is both an `Operation>` (`yield*` for the completion Result) and has `.output` stream for chunks; once a handle exists every failure resolves `Err(error)` — completion never throws, so policy middleware (e.g. testing) can map outcomes without exception control flow | | 74 | Function components receive props directly, not wrapped | `function*(props)` not `function*({ props })` — eliminates unnecessary destructuring; props are already validated by the expansion engine before the function is called | | 75 | `useContent()` is contextual, not a function argument | Decouples function components from the expansion engine's API surface; leaf components don't need to ignore an `expandChildren` parameter; Effection-idiomatic — same contextual pattern as `env`/`evalScope`; supports named slots via `useContent("header")` | | 76 | `.md` wins over `.ts` in resolution | Backward compatibility — existing markdown components are not shadowed by TypeScript files added later; explicit — if both exist, the human-readable markdown is preferred | diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 6dba543..cf8d755 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -47,7 +47,7 @@ sequenceDiagram ## 2. Version lockstep -Every package (`core`, `cli`, `durable-streams`, `runtime`, +Every package (`core`, `cli`, `durable-streams`, `runtime`, `testing`, `packages/code-review-agent`) declares the same version in its `deno.json` and `package.json`. `cli/src/cli.ts` imports `cli/deno.json` and reads `version` from it, so the compiled binary reports the manifest version — the manifests @@ -146,7 +146,7 @@ versions succeed. No dispatch path publishes outside a tag. ## 8. Consumer note -`@executablemd/core`, `@executablemd/runtime`, and `@executablemd/cli` depend -on effection's 4.x prerelease, which npm's peer resolver rejects against -`@effectionx/*` (`^3 || ^4`). Installing them requires `--legacy-peer-deps` -until effection 4 is stable. +`@executablemd/core`, `@executablemd/runtime`, `@executablemd/testing`, and +`@executablemd/cli` depend on effection's 4.x prerelease, which npm's peer +resolver rejects against `@effectionx/*` (`^3 || ^4`). Installing them +requires `--legacy-peer-deps` until effection 4 is stable. diff --git a/specs/testing-spec.md b/specs/testing-spec.md new file mode 100644 index 0000000..be6fb3a --- /dev/null +++ b/specs/testing-spec.md @@ -0,0 +1,188 @@ +# Executable.md Testing + +## Motivation + +Executable documents can contain probabilistic behavior. Tests ground that +behavior in observable results and give authors confidence in their documents. + +## Testing Mode + +Tests run only in testing mode. During regular execution, `` and its +entire body are skipped without output, bindings, or side effects. + +`` enables testing mode for its expanded subtree: + +```md + + + +``` + +The CLI command is equivalent to wrapping the entrypoint in ``: + +```sh +xmd test +``` + +Test discovery follows normal component expansion. A test runs only when the +expanded component tree reaches it, including through imports, components, and +conditional rendering. Imported but unrendered tests do not run. + +Ordinary content outside a `` expands normally. An error in that content +aborts the run as an infrastructure error. + +`` emits the naturally expanded report without adding a summary. It +fails after expansion when any test failed or when no tests were discovered. +`xmd test` exits with status `0` when every test passes and status `1` +otherwise. + +Testing uses the standard journal and replay behavior. Each completed test +and each `` boundary outcome is journaled as a testing-owned +durable operation (`test_result`, `testing_boundary`) while expansion runs, +before the root close event, identified deterministically by source +position. On a full replay of a completed journal the recorded results and +boundary outcomes are restored from the stream into the current collectors +— test bodies and their effects are not rerun — so the testing outcome and +recorded results are preserved. On live and partial runs nothing is +restored: re-expansion records each result exactly once, in discovery +order, with completed records replaying in place. + +## Atomic Tests + +Atomic tests use ``: + +```md + + + Hello World + + + Hello World + + +``` + +The `name` prop is optional metadata. An unnamed test is identified by its +source location; headings in the body remain ordinary output and are not +inferred as names. + +A test body behaves like any regular component body. Tests run sequentially in +expansion order. Each test runs in a child Effection scope and an isolated +binding environment. It inherits ambient context and bindings, but its context +changes, bindings, and ongoing effects do not escape. Its scope is fully torn +down before the next test starts. + +Each test has a fixed 20-second timeout. A timed-out test is halted, reported as +failed, and fully torn down before execution continues. + +An assertion failure, unexpected error, or teardown error fails only the current +test. Later tests still run. Unexpected errors remain distinct from assertion +failures. Output produced before a failure remains in the report and is followed +by the failure diagnostic. + +Nested `` elements are invalid. Skip, focus, and retry behavior is not +supported. + +## Test API + +`TestApi` controls testing mode. The `testing` operation returns `false` by +default and `true` beneath ``: + +```ts +import { testing } from "@executablemd/testing"; + +const active = yield* testing; +``` + +`TestApi` records completed tests in discovery order. Each result contains its +pass or fail status, optional name, source location, and structured error +details when it failed. Rendered test output is not duplicated in the result. + +`useTesting` composes testing around the core execution entrypoint. It +installs the testing vocabulary and collectors, activates testing mode for +the execution, and returns a session whose `results` operation snapshots +completed tests in discovery order: + +```ts +import { execute } from "@executablemd/core"; +import { useTesting } from "@executablemd/testing"; + +const tests = yield* useTesting(); +const execution = yield* execute(options); +const outcome = yield* execution; +const results = yield* tests.results; +``` + +Execution completion is an Effection `Result`: `Ok(output)` on +success, `Err(error)` on document, infrastructure, or testing failure — +completion never throws once the execution handle exists. Under +`useTesting`, an otherwise successful execution completes as +`Err(TestFailureError)` after the output stream closes when any test failed +or no tests were discovered. A failure produced by the document itself +passes through unchanged, and the session's results remain available after +failure. One `useTesting` session applies per execution scope; its +middleware is removed with that scope. `xmd test` composes `useTesting` +around the same `execute` call the `run` command uses. + +Registering the testing vocabulary without `useTesting` leaves testing mode +inactive: `` is skipped, assertion components stay usable, and an +explicit `` boundary still activates its subtree and turns its +failures — or an empty boundary — into an `Err` outcome for the execution. + +## Assertions + +Assertion components use `@std/assert` and follow its function names and +parameter names. The initial components are: + +- `` and `` +- `` and `` +- `` and `` +- `` +- `` +- `` and `` +- `` and `` +- `` and `` + +Props map directly to the corresponding function parameters: `expr` for +truthiness assertions, `actual` and `expected` for comparisons, and optional +`msg` where supported. + +Equality assertions and `` accept either an `expected` +prop or rendered children as the expected string. The two forms are mutually +exclusive. Expected children expand in the current scope and environment, use +the same trailing-whitespace trimming as ``, and do not render +separately. + +Numeric comparisons require an `expected` prop. Match assertions require a +`RegExp` through the `expected` prop. Unary assertions do not accept expected +children. + +Assertion components work inside and outside tests. A failed assertion throws +the `@std/assert` assertion error. Outside a test, that error aborts document +expansion. Inside a test, `` contains and records it. + +Assertions emit Markdown diagnostics in testing mode. During regular execution, +diagnostics are hidden unless `--verbose` is enabled. Failed assertions still +throw when diagnostics are hidden. + +Diagnostics identify the assertion component and outcome. They include the +optional message and relevant actual and expected values. Failure diagnostics +include the underlying assertion detail when available. Their exact Markdown +layout is not prescribed, and formatting arbitrary values must not change the +assertion outcome or introduce a new failure. + +Additional assertion components use the same rules: their names and props map to +an `@std/assert` export, they preserve its comparison and error semantics, and +they use the shared diagnostic behavior. + +## Mocking + +Testing has no separate mocking DSL. Tests install mocks through existing +context API middleware or helpers from `@executablemd/runtime/test`. Middleware +installed within a test applies to subsequent expansion in that test and is +removed with its scope. + +## Unsupported Syntax + +BDD syntax such as `describe`, `it`, `beforeEach`, and `beforeAll` is not +supported. Gherkin syntax such as `Given`, `When`, and `Then` is not supported. diff --git a/testing/deno.json b/testing/deno.json new file mode 100644 index 0000000..a3916cb --- /dev/null +++ b/testing/deno.json @@ -0,0 +1,11 @@ +{ + "name": "@executablemd/testing", + "version": "0.3.1", + "exports": { + ".": "./mod.ts" + }, + "imports": { + "@executablemd/core": "../core/mod.ts", + "@std/assert": "jsr:@std/assert@^1.0.17" + } +} diff --git a/testing/mod.ts b/testing/mod.ts new file mode 100644 index 0000000..691da31 --- /dev/null +++ b/testing/mod.ts @@ -0,0 +1,29 @@ +/** + * @module + * Testing vocabulary for executable.md documents (specs/testing-spec.md). + * + * `` activates testing mode for its expanded subtree, `` + * defines an atomic test, and the assertion components map to `@std/assert`. + * + * Composition with core execution: + * + * ```ts + * import { execute } from "@executablemd/core"; + * import { useTesting } from "@executablemd/testing"; + * + * const tests = yield* useTesting(); + * const execution = yield* execute(options); + * const outcome = yield* execution; // Result + * const results = yield* tests.results; + * ``` + * + * Vocabulary registration (`installTestingVocabulary`) is distinct from + * testing-mode activation (`useTesting()` at the root, or a `` + * element for a subtree). + */ + +export { Test, testing, record, results, TestFailureError } from "./src/test-api.ts"; +export type { TestApi, TestResult, BoundaryOutcome } from "./src/test-api.ts"; +export { installTestingVocabulary } from "./src/vocabulary.ts"; +export { useTesting } from "./src/use-testing.ts"; +export type { Testing } from "./src/use-testing.ts"; diff --git a/testing/package.json b/testing/package.json new file mode 100644 index 0000000..674f45b --- /dev/null +++ b/testing/package.json @@ -0,0 +1,18 @@ +{ + "name": "@executablemd/testing", + "version": "0.3.1", + "description": "Testing vocabulary for executable.md documents: , , and assertion components.", + "type": "module", + "exports": { + ".": "./mod.ts" + }, + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/scope-eval": "0.1.3", + "@effectionx/timebox": "0.4.3", + "@executablemd/core": "workspace:*", + "@executablemd/durable-streams": "workspace:*", + "@std/assert": "npm:@jsr/std__assert@^1.0.17", + "effection": "4.1.0-alpha.7" + } +} diff --git a/testing/src/assertions.ts b/testing/src/assertions.ts new file mode 100644 index 0000000..080d6e3 --- /dev/null +++ b/testing/src/assertions.ts @@ -0,0 +1,361 @@ +/** + * Assertion components (specs/testing-spec.md §Assertions). + * + * Each component maps to an `@std/assert` export with the same name and + * parameter names. Expression props evaluate LIVE against the merged binding + * environment — never through JSON serialization, which would destroy + * `RegExp`s, `undefined`, and object identity. + * + * The assertion runs on the raw values BEFORE any diagnostic formatting, so + * formatting arbitrary values (mutating or throwing getters/toJSON/toString) + * can never change the assertion outcome. Diagnostics are built afterwards + * under guarded fallback. + */ + +import { + assert, + assertEquals, + assertExists, + assertFalse, + assertGreater, + assertGreaterOrEqual, + AssertionError, + assertLess, + assertLessOrEqual, + assertMatch, + assertNotEquals, + assertNotMatch, + assertNotStrictEquals, + assertStrictEquals, + assertStringIncludes, +} from "@std/assert"; +import type { Operation } from "effection"; +import { DocumentOutput, env, renderSegments } from "@executablemd/core"; +import type { + ComponentInvocation, + ErrorSegment, + InvocationContext, + Segment, +} from "@executablemd/core"; +import { inTest, testing, verbose } from "./test-api.ts"; + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +type AssertionKind = + | "unary-truthy" + | "unary-exists" + | "binary-eq" + | "string-includes" + | "match" + | "numeric"; + +export interface AssertionEntry { + name: string; + kind: AssertionKind; + /** Runs the underlying @std/assert function on the resolved raw values. */ + run(values: ResolvedValues): void; + allowsExpectedChildren: boolean; +} + +interface ResolvedValues { + expr?: unknown; + actual?: unknown; + expected?: unknown; + msg?: string; +} + +function entry( + name: string, + kind: AssertionKind, + run: (values: ResolvedValues) => void, +): [string, AssertionEntry] { + const allowsExpectedChildren = kind === "binary-eq" || kind === "string-includes"; + return [name, { name, kind, run, allowsExpectedChildren }]; +} + +export const ASSERTIONS: Map = new Map([ + entry("Assert", "unary-truthy", (v) => assert(v.expr, v.msg)), + entry("AssertFalse", "unary-truthy", (v) => assertFalse(v.expr, v.msg)), + entry("AssertExists", "unary-exists", (v) => assertExists(v.actual, v.msg)), + entry("AssertEquals", "binary-eq", (v) => assertEquals(v.actual, v.expected, v.msg)), + entry("AssertNotEquals", "binary-eq", (v) => assertNotEquals(v.actual, v.expected, v.msg)), + entry("AssertStrictEquals", "binary-eq", (v) => assertStrictEquals(v.actual, v.expected, v.msg)), + entry("AssertNotStrictEquals", "binary-eq", (v) => + assertNotStrictEquals(v.actual, v.expected, v.msg), + ), + entry("AssertStringIncludes", "string-includes", (v) => + assertStringIncludes(coerceString(v.actual), coerceString(v.expected), v.msg), + ), + entry("AssertMatch", "match", (v) => + assertMatch(coerceString(v.actual), requireRegExp(v.expected), v.msg), + ), + entry("AssertNotMatch", "match", (v) => + assertNotMatch(coerceString(v.actual), requireRegExp(v.expected), v.msg), + ), + entry("AssertGreater", "numeric", (v) => assertGreater(v.actual, v.expected, v.msg)), + entry("AssertGreaterOrEqual", "numeric", (v) => + assertGreaterOrEqual(v.actual, v.expected, v.msg), + ), + entry("AssertLess", "numeric", (v) => assertLess(v.actual, v.expected, v.msg)), + entry("AssertLessOrEqual", "numeric", (v) => assertLessOrEqual(v.actual, v.expected, v.msg)), +]); + +function coerceString(value: unknown): string { + if (typeof value === "string") { + return value; + } + throw new AssertionError(`expected a string "actual"/"expected" value, got ${typeof value}`); +} + +function requireRegExp(value: unknown): RegExp { + if (value instanceof RegExp) { + return value; + } + throw new AssertionError( + "match assertions require a RegExp through the expected prop — use expected={/pattern/}", + ); +} + +// --------------------------------------------------------------------------- +// Live expression evaluation +// --------------------------------------------------------------------------- + +const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +function evaluateExpression(expression: string, values: Record): unknown { + const names = Object.keys(values).filter((name) => IDENTIFIER_RE.test(name)); + const fn = new Function(...names, `return (${expression});`); + return fn(...names.map((name) => values[name])); +} + +// --------------------------------------------------------------------------- +// Guarded value formatting — runs only AFTER the assertion outcome is fixed +// --------------------------------------------------------------------------- + +function safeFormat(value: unknown): string { + try { + if (typeof value === "string") { + return JSON.stringify(value); + } + if (value instanceof RegExp) { + return String(value); + } + const json = JSON.stringify(value); + if (json !== undefined) { + return json; + } + return String(value); + } catch { + try { + return String(value); + } catch { + return ""; + } + } +} + +// --------------------------------------------------------------------------- +// Diagnostic-carrying assertion error +// --------------------------------------------------------------------------- + +/** + * An assertion failure enriched with its Markdown diagnostic. Still an + * `AssertionError`, so containment and classification treat it as the + * original @std/assert failure. + */ +export class AssertionDiagnostic extends AssertionError { + override name = "AssertionDiagnostic"; + diagnostic: string; + detail: { actual?: string; expected?: string }; + + constructor(cause: Error, diagnostic: string, detail: { actual?: string; expected?: string }) { + super(cause.message); + this.diagnostic = diagnostic; + this.detail = detail; + this.cause = cause; + } +} + +// --------------------------------------------------------------------------- +// expandAssertion +// --------------------------------------------------------------------------- + +const KIND_PROPS: Record = { + "unary-truthy": { allowed: ["expr", "msg"], required: ["expr"] }, + "unary-exists": { allowed: ["actual", "msg"], required: ["actual"] }, + "binary-eq": { allowed: ["actual", "expected", "msg"], required: ["actual"] }, + "string-includes": { allowed: ["actual", "expected", "msg"], required: ["actual"] }, + match: { allowed: ["actual", "expected", "msg"], required: ["actual", "expected"] }, + numeric: { allowed: ["actual", "expected", "msg"], required: ["actual", "expected"] }, +}; + +function validationError(name: string, message: string): ErrorSegment { + return { type: "error", message: `<${name}> ${message}`, source: name }; +} + +/** + * Expand one assertion component: validate props, resolve raw values, run the + * @std/assert function, then build the diagnostic. Returns diagnostic text on + * a visible pass; throws `AssertionDiagnostic` on failure. + */ +export function* expandAssertion( + assertion: AssertionEntry, + invocation: ComponentInvocation, + ctx: InvocationContext, +): Operation { + const rules = KIND_PROPS[assertion.kind]; + const supplied = [...Object.keys(invocation.props), ...Object.keys(invocation.expressions)]; + + for (const name of supplied) { + if (!rules.allowed.includes(name)) { + return [ + validationError( + assertion.name, + `does not accept a "${name}" prop (allowed: ${rules.allowed.join(", ")}).`, + ), + ]; + } + } + + const hasChildren = !invocation.selfClosing && invocation.children.length > 0; + if (hasChildren && !assertion.allowsExpectedChildren) { + return [validationError(assertion.name, "does not accept expected children.")]; + } + if (hasChildren && supplied.includes("expected")) { + return [ + validationError( + assertion.name, + 'accepts either an "expected" prop or expected children, not both.', + ), + ]; + } + + for (const name of rules.required) { + const suppliedByChildren = name === "expected" && hasChildren; + if (!supplied.includes(name) && !suppliedByChildren) { + return [validationError(assertion.name, `requires the "${name}" prop.`)]; + } + } + if (assertion.kind === "binary-eq" || assertion.kind === "string-includes") { + if (!supplied.includes("expected") && !hasChildren) { + return [validationError(assertion.name, 'requires an "expected" prop or expected children.')]; + } + } + + // Resolve raw values: literal props as-is, expression props evaluated live + // against caller-projected bindings merged under the current environment + // (the same precedence core uses for projected children). + const currentEnv = yield* env; + const merged = { + ...(ctx.projectedEnv?.values ?? {}), + ...(currentEnv?.values ?? {}), + }; + + const resolved: Record = {}; + const resolutionOrder: Array<"expr" | "actual" | "expected" | "msg"> = [ + "expr", + "actual", + "expected", + "msg", + ]; + for (const name of resolutionOrder) { + if (name in invocation.expressions) { + try { + resolved[name] = evaluateExpression(invocation.expressions[name]!, merged); + } catch (error) { + return [ + validationError( + assertion.name, + `failed to evaluate the "${name}" expression: ${ + error instanceof Error ? error.message : String(error) + }`, + ), + ]; + } + } else if (name in invocation.props) { + resolved[name] = invocation.props[name]; + } + } + + // @std/assert takes msg as a string; a non-string is rejected by TYPE + // CHECK alone — formatting it here would run hostile toJSON/toString + // side effects before the assertion outcome is established. + if ("msg" in resolved && typeof resolved.msg !== "string") { + return [validationError(assertion.name, 'requires "msg" to be a string when supplied.')]; + } + + const values: ResolvedValues = { + expr: resolved.expr, + actual: resolved.actual, + expected: resolved.expected, + msg: typeof resolved.msg === "string" ? resolved.msg : undefined, + }; + + if (hasChildren) { + const expanded = yield* ctx.expand(invocation.children); + values.expected = renderSegments(expanded).replace(/\s+$/, ""); + } + + // Run the assertion on the raw values — the outcome is fixed before any + // diagnostic formatting can observe (or mutate) them. + let failure: Error | undefined; + try { + assertion.run(values); + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); + } + + const detail: { actual?: string; expected?: string } = {}; + if (assertion.kind === "unary-truthy") { + detail.actual = safeFormat(values.expr); + } else { + detail.actual = safeFormat(values.actual); + if (assertion.kind !== "unary-exists") { + detail.expected = safeFormat(values.expected); + } + } + + if (failure) { + const diagnostic = buildDiagnostic(assertion.name, "failed", values.msg, detail, failure); + const visible = (yield* testing) || (yield* verbose); + const inTestScope = yield* inTest; + if (visible && !inTestScope) { + // Outside a test the throw below aborts expansion before the segment + // could render — emit the diagnostic directly so it reaches the output. + yield* DocumentOutput.operations.output(diagnostic); + } + throw new AssertionDiagnostic(failure, diagnostic, detail); + } + + const visible = (yield* testing) || (yield* verbose); + if (!visible) { + return []; + } + return [{ type: "text", content: buildDiagnostic(assertion.name, "passed", values.msg, detail) }]; +} + +function buildDiagnostic( + name: string, + outcome: "passed" | "failed", + msg: string | undefined, + detail: { actual?: string; expected?: string }, + failure?: Error, +): string { + const icon = outcome === "passed" ? "✅" : "❌"; + const lines = [`> ${icon} **${name}** ${outcome}${msg ? ` — ${msg}` : ""}`]; + if (detail.actual !== undefined) { + lines.push(`> actual: ${detail.actual}`); + } + if (detail.expected !== undefined) { + lines.push(`> expected: ${detail.expected}`); + } + if (failure) { + const message = failure.message.split("\n")[0]; + if (message) { + lines.push(`> ${message}`); + } + } + return `\n${lines.join("\n")}\n`; +} diff --git a/testing/src/handlers.ts b/testing/src/handlers.ts new file mode 100644 index 0000000..8361fcb --- /dev/null +++ b/testing/src/handlers.ts @@ -0,0 +1,330 @@ +/** + * `` and `` handlers (specs/testing-spec.md). + * + * `createTestHandlers` is the internal dependency-injection seam for the + * fixed 20-second test timeout: the public vocabulary always constructs + * handlers with 20_000; tests construct them directly with a small timeout. + */ + +import { ensure, scoped, spawn, suspend, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { timebox } from "@effectionx/timebox"; +import { unbox, useEvalScope } from "@effectionx/scope-eval"; +import type { EvalScope } from "@effectionx/scope-eval"; +import { AssertionError } from "@std/assert"; +import { Component, env, evalScope } from "@executablemd/core"; +import type { + ComponentInvocation, + ErrorSegment, + EvalEnv, + InvocationContext, + Segment, +} from "@executablemd/core"; +import { Test, boundary, inTest, record, testing } from "./test-api.ts"; +import type { TestResult } from "./test-api.ts"; +import { AssertionDiagnostic, expandAssertion } from "./assertions.ts"; +import type { AssertionEntry } from "./assertions.ts"; +import { persistBoundaryOutcome, persistTestResult } from "./journal.ts"; + +// --------------------------------------------------------------------------- +// Contained-failure carriers +// --------------------------------------------------------------------------- + +/** An ErrorSegment raised anywhere inside a test body. */ +class RaisedSegmentError extends Error { + override name = "RaisedSegmentError"; + segment: ErrorSegment; + + constructor(segment: ErrorSegment) { + super(segment.message); + this.segment = segment; + } +} + +/** A failure while dismantling an established test scope or lease. */ +class TeardownError extends Error { + override name = "TeardownError"; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)); + this.cause = cause; + } +} + +// --------------------------------------------------------------------------- +// Test-owned child EvalScope lease +// --------------------------------------------------------------------------- + +interface EvalScopeLease { + scope: EvalScope; + task: Task; +} + +/** + * Host a child EvalScope in a dedicated task inside the parent EvalScope. + * The child inherits the parent's middleware, but its lifetime belongs to + * the TEST: `parentScope.eval(() => useEvalScope())` alone would tie the + * worker to the parent scope, leaking test-installed middleware into later + * tests. The suspended task keeps the child alive until the lease is halted. + */ +function* leaseChildEvalScope(parentScope: EvalScope): Operation { + const published = withResolvers(); + const boxed = yield* parentScope.eval(function* () { + return yield* spawn(function* () { + published.resolve(yield* useEvalScope()); + yield* suspend(); + }); + }); + return { scope: yield* published.operation, task: unbox(boxed) }; +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export interface TestHandlers { + expandTesting(invocation: ComponentInvocation, ctx: InvocationContext): Operation; + expandTest(invocation: ComponentInvocation, ctx: InvocationContext): Operation; + expandAssertion( + assertion: AssertionEntry, + invocation: ComponentInvocation, + ctx: InvocationContext, + ): Operation; +} + +export function createTestHandlers(options: { timeoutMs: number }): TestHandlers { + const { timeoutMs } = options; + + function* expandTesting( + invocation: ComponentInvocation, + ctx: InvocationContext, + ): Operation { + return yield* scoped(function* () { + const local: TestResult[] = []; + yield* Test.around( + { + testing: () => true, + // deno-lint-ignore require-yield + *results() { + return local; + }, + *record([result], next) { + local.push(result); + yield* next(result); + }, + }, + { at: "min" }, + ); + const report = yield* ctx.expand(invocation.children); + // Journal the outcome before the root Close so a full replay can + // restore it without re-expanding this boundary. + const outcome = yield* persistBoundaryOutcome( + { + tests: local.length, + failed: local.filter((result) => result.status === "fail").length, + }, + formatLocation(invocation), + ); + yield* boundary(outcome); + return report; + }); + } + + function* expandTest( + invocation: ComponentInvocation, + ctx: InvocationContext, + ): Operation { + if (!(yield* testing)) { + return []; + } + if (yield* inTest) { + // Returned while the ENCLOSING test's raise interceptor is still + // active — the hook's re-raise fails the current test. + const error: ErrorSegment = { + type: "error", + message: "Nested elements are invalid.", + source: "Test", + }; + return [error]; + } + + const name = typeof invocation.props.name === "string" ? invocation.props.name : undefined; + const location = formatLocation(invocation); + + const parentEnv = yield* env; + const parentScope = yield* evalScope; + if (!parentScope) { + const result = yield* persistTestResult( + failResult(name, location, { + kind: "error", + message: " requires an eval scope in context.", + }), + ); + yield* record(result); + return [failureDiagnostic(result, { detail: true })]; + } + + // ONE stable binding environment, created before middleware install — + // the accessor returns the same object on every read, so + // writes persist for the assertion that follows. Caller-projected + // bindings merge UNDER the current environment (core's precedence, + // expand.ts §content projection), so a projected through + // still sees the caller's eval bindings. + const testEnv: EvalEnv = { + values: { + ...(ctx.projectedEnv?.values ?? {}), + ...(parentEnv?.values ?? {}), + }, + }; + + const testOutput: Segment[] = []; + let bodyError: unknown; + let timedOut = false; + let established = false; + + try { + yield* scoped(function* () { + const lease = yield* leaseChildEvalScope(parentScope); + // Halt the lease during this scope's teardown, before the next test + // can start. A throwing halt propagates as a teardown failure. + yield* ensure(() => lease.task.halt()); + + yield* Component.around( + { + env: () => testEnv, + evalScope: () => lease.scope, + }, + { at: "min" }, + ); + yield* Test.around({ inTest: () => true }, { at: "min" }); + // ErrorSegments fail the test. Outer instrumentation (default "max") + // so nested { at: "min" } policies cannot shadow it: every raise in + // the body — components, regions, code blocks, imports, + // validation, nested — arrives here first. + yield* Component.around({ + // deno-lint-ignore require-yield + *raise([segment]) { + throw new RaisedSegmentError(segment); + }, + }); + established = true; + + const boxed = yield* timebox(timeoutMs, function* () { + for (const child of invocation.children) { + try { + testOutput.push(...(yield* ctx.expand([child]))); + } catch (error) { + bodyError = error; + throw error; + } + } + }); + if (boxed.timeout) { + timedOut = true; + } + }); + } catch (outer) { + if (bodyError === undefined && !timedOut) { + // Setup failures (lease creation, middleware install) are unexpected + // errors; only failures dismantling an ESTABLISHED scope/lease are + // teardown failures. + bodyError = established ? new TeardownError(outer) : outer; + } + } + + // Journal the result before the root Close. On partial replay the + // stored record wins over the recomputation (short-circuited effects + // can change what the re-run observes, e.g. a halted exec no longer + // times out), keeping the original outcome authoritative. + const result = yield* persistTestResult( + classify(name, location, bodyError, timedOut, timeoutMs), + ); + yield* record(result); + + if (result.status === "fail") { + // Containment invariant: a completed test returns only text segments. + // The hook re-raises returned ErrorSegments under the AMBIENT policy — + // after this test's interception scope has ended — so raised segments + // are formatted into the diagnostic instead of returned raw. + if (bodyError instanceof AssertionDiagnostic) { + // The assertion's own diagnostic (built when it threw) follows the + // output produced before the failure, then the test-level line. + testOutput.push({ type: "text", content: bodyError.diagnostic }); + testOutput.push(failureDiagnostic(result, { detail: false })); + } else { + testOutput.push(failureDiagnostic(result, { detail: true })); + } + } + return testOutput; + } + + return { expandTesting, expandTest, expandAssertion }; +} + +// --------------------------------------------------------------------------- +// Result classification and diagnostics +// --------------------------------------------------------------------------- + +function formatLocation(invocation: ComponentInvocation): string { + const position = invocation.position; + if (!position) { + return "unknown"; + } + const at = `${position.line}:${position.column}`; + return position.path ? `${position.path}:${at}` : at; +} + +function failResult( + name: string | undefined, + location: string, + error: NonNullable, +): TestResult { + return { status: "fail", name, location, error }; +} + +function classify( + name: string | undefined, + location: string, + bodyError: unknown, + timedOut: boolean, + timeoutMs: number, +): TestResult { + if (bodyError === undefined && !timedOut) { + return { status: "pass", name, location }; + } + if (timedOut && bodyError === undefined) { + return failResult(name, location, { + kind: "timeout", + message: `test timed out after ${timeoutMs / 1000} seconds`, + }); + } + if (bodyError instanceof AssertionDiagnostic) { + return failResult(name, location, { + kind: "assertion", + message: bodyError.message, + actual: bodyError.detail.actual, + expected: bodyError.detail.expected, + }); + } + if (bodyError instanceof AssertionError) { + return failResult(name, location, { kind: "assertion", message: bodyError.message }); + } + if (bodyError instanceof TeardownError) { + return failResult(name, location, { kind: "teardown", message: bodyError.message }); + } + const message = bodyError instanceof Error ? bodyError.message : String(bodyError); + return failResult(name, location, { kind: "error", message }); +} + +function failureDiagnostic(result: TestResult, options: { detail: boolean }): Segment { + const title = result.name ? `**${result.name}**` : `test at ${result.location}`; + const error = result.error; + const lines = [`> ❌ Test ${title} failed (${error?.kind ?? "error"}): ${error?.message ?? ""}`]; + if (options.detail && error?.actual !== undefined) { + lines.push(`> actual: ${error.actual}`); + } + if (options.detail && error?.expected !== undefined) { + lines.push(`> expected: ${error.expected}`); + } + return { type: "text", content: `\n${lines.join("\n")}\n` }; +} diff --git a/testing/src/journal.ts b/testing/src/journal.ts new file mode 100644 index 0000000..6cdce80 --- /dev/null +++ b/testing/src/journal.ts @@ -0,0 +1,182 @@ +/** + * Durable testing records (specs/testing-spec.md §Testing Mode). + * + * Completed tests and explicit `` boundary outcomes are journaled + * as testing-owned durable operations while document expansion runs — + * before the root Close event. On a full replay of a completed journal, + * `durableRun` returns the stored root result without re-expanding the + * document, so no test would ever re-record; the stored records are + * restored from the stream instead. On partial replay the document + * re-expands and each record replays in place, recording exactly once in + * discovery order. + * + * Record identities are deterministic, derived from source position. + */ + +import { createDurableOperation } from "@executablemd/durable-streams"; +import type { DurableStream, Json, Workflow } from "@executablemd/durable-streams"; +import type { Operation } from "effection"; +import type { BoundaryOutcome, TestResult } from "./test-api.ts"; + +const TEST_RESULT = "test_result"; +const TESTING_BOUNDARY = "testing_boundary"; + +export function* persistTestResult(result: TestResult): Workflow { + const stored = yield createDurableOperation( + { type: TEST_RESULT, name: `test:${result.location}` }, + // deno-lint-ignore require-yield + function* (): Operation { + return serializeTestResult(result); + }, + ); + const parsed = parseTestResult(stored); + if (!parsed) { + throw new Error(`journaled test_result for "${result.location}" has an unexpected shape`); + } + return parsed; +} + +export function* persistBoundaryOutcome( + outcome: BoundaryOutcome, + location: string, +): Workflow { + const stored = yield createDurableOperation( + { type: TESTING_BOUNDARY, name: `testing:${location}` }, + // deno-lint-ignore require-yield + function* (): Operation { + return { tests: outcome.tests, failed: outcome.failed }; + }, + ); + const parsed = parseBoundaryOutcome(stored); + if (!parsed) { + throw new Error(`journaled testing_boundary for "${location}" has an unexpected shape`); + } + return parsed; +} + +export interface CompletedRunRecords { + results: TestResult[]; + boundaries: BoundaryOutcome[]; +} + +/** + * Read testing records from a journal that already holds a root Close + * event — the confirmed-full-replay case. Returns undefined for a live or + * partial journal, where expansion itself (re)records each result. + */ +export function* readCompletedRun( + stream: DurableStream, +): Operation { + const events = yield* stream.readAll(); + const completed = events.some((event) => event.type === "close" && event.coroutineId === "root"); + if (!completed) { + return undefined; + } + + const results: TestResult[] = []; + const boundaries: BoundaryOutcome[] = []; + for (const event of events) { + if (event.type !== "yield" || event.result.status !== "ok") { + continue; + } + if (event.description.type === TEST_RESULT) { + const parsed = parseTestResult(event.result.value); + if (parsed) { + results.push(parsed); + } + } else if (event.description.type === TESTING_BOUNDARY) { + const parsed = parseBoundaryOutcome(event.result.value); + if (parsed) { + boundaries.push(parsed); + } + } + } + return { results, boundaries }; +} + +function serializeTestResult(result: TestResult): Json { + const payload: Record = { + status: result.status, + location: result.location, + }; + if (result.name !== undefined) { + payload.name = result.name; + } + if (result.error) { + const error: Record = { + kind: result.error.kind, + message: result.error.message, + }; + if (result.error.actual !== undefined) { + error.actual = result.error.actual; + } + if (result.error.expected !== undefined) { + error.expected = result.error.expected; + } + payload.error = error; + } + return payload; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === "string"; +} + +const ERROR_KINDS = ["assertion", "timeout", "teardown", "error"]; + +function parseTestResult(value: unknown): TestResult | undefined { + if (!isRecord(value)) { + return undefined; + } + const { status, location, name, error } = value; + if (status !== "pass" && status !== "fail") { + return undefined; + } + if (typeof location !== "string" || !optionalString(name)) { + return undefined; + } + const result: TestResult = { status, location }; + if (name !== undefined) { + result.name = name; + } + if (error !== undefined) { + if (!isRecord(error)) { + return undefined; + } + const { kind, message, actual, expected } = error; + if (typeof kind !== "string" || !ERROR_KINDS.includes(kind)) { + return undefined; + } + if ( + typeof message !== "string" || + !optionalString(actual) || + !optionalString(expected) || + (kind !== "assertion" && kind !== "timeout" && kind !== "teardown" && kind !== "error") + ) { + return undefined; + } + result.error = { kind, message }; + if (actual !== undefined) { + result.error.actual = actual; + } + if (expected !== undefined) { + result.error.expected = expected; + } + } + return result; +} + +function parseBoundaryOutcome(value: unknown): BoundaryOutcome | undefined { + if (!isRecord(value)) { + return undefined; + } + const { tests, failed } = value; + if (typeof tests !== "number" || typeof failed !== "number" || tests < 0 || failed < 0) { + return undefined; + } + return { tests, failed }; +} diff --git a/testing/src/test-api.ts b/testing/src/test-api.ts new file mode 100644 index 0000000..b2faf21 --- /dev/null +++ b/testing/src/test-api.ts @@ -0,0 +1,76 @@ +/** + * Test Api — contextual operations for testing mode (specs/testing-spec.md). + * + * `testing` is the activation switch: false by default, true beneath + * `` (or root activation via a `useTesting()` session). + * Both perform the identical `Test.around({ testing: () => true })` install, + * which is what makes `xmd test` equivalent to wrapping the entrypoint in + * ``. + * + * `record` delegates outward through nested collectors, so every enclosing + * `` boundary and the run-level collector observe each completed + * test. `boundary` reports each `` element's aggregate outcome. + */ + +import { createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +/** A completed test, in discovery order. Never holds rendered markdown. */ +export interface TestResult { + status: "pass" | "fail"; + name?: string; + /** "path:line:column" ("line:column" for dynamically scanned sources). */ + location: string; + error?: { + kind: "assertion" | "timeout" | "teardown" | "error"; + message: string; + actual?: string; + expected?: string; + }; +} + +/** Aggregate outcome of one `` boundary. */ +export interface BoundaryOutcome { + tests: number; + failed: number; +} + +export interface TestApi { + /** Whether testing mode is active in the current scope. */ + testing: boolean; + /** Whether expansion is currently inside a `` body. */ + inTest: boolean; + /** Whether assertion diagnostics render during regular execution. */ + verbose: boolean; + /** Whether a useTesting() session is already active in this scope. */ + sessionActive: boolean; + /** Record a completed test. Collectors delegate outward via `next`. */ + record(result: TestResult): Operation; + /** Completed tests recorded by the nearest collector, discovery order. */ + results(): Operation; + /** Report a `` boundary's aggregate outcome. */ + boundary(outcome: BoundaryOutcome): Operation; +} + +export const Test = createApi("Test", { + testing: false, + inTest: false, + verbose: false, + sessionActive: false, + // deno-lint-ignore require-yield + *record(_result: TestResult): Operation {}, + // deno-lint-ignore require-yield + *results(): Operation { + return []; + }, + // deno-lint-ignore require-yield + *boundary(_outcome: BoundaryOutcome): Operation {}, +}); + +export const { testing, inTest, verbose, sessionActive, record, results, boundary } = + Test.operations; + +/** A document execution failed its testing outcome (test failures or zero tests). */ +export class TestFailureError extends Error { + override name = "TestFailureError"; +} diff --git a/testing/src/use-testing.ts b/testing/src/use-testing.ts new file mode 100644 index 0000000..bea1145 --- /dev/null +++ b/testing/src/use-testing.ts @@ -0,0 +1,102 @@ +/** + * useTesting — scope-local testing composition (specs/testing-spec.md). + * + * One `useTesting()` session per execution scope: it installs the testing + * vocabulary, the collection and completion-policy middleware, and root + * activation, then returns a session handle whose `results` operation + * snapshots completed tests in discovery order. Every install is removed + * with the session's Effection scope. + * + * ```ts + * const tests = yield* useTesting(); + * const execution = yield* execute(options); + * const outcome = yield* execution; // Result + * const results = yield* tests.results; + * ``` + */ + +import type { Operation } from "effection"; +import { Execution } from "@executablemd/core"; +import { sessionActive, Test, TestFailureError } from "./test-api.ts"; +import type { TestResult } from "./test-api.ts"; +import { decorateCompletion, installTestingVocabulary } from "./vocabulary.ts"; + +export interface Testing { + /** Immutable snapshot of completed tests, in discovery order. */ + readonly results: Operation; +} + +export function* useTesting(options?: { verbose?: boolean }): Operation { + if (yield* sessionActive) { + throw new Error( + "useTesting() is already active in this scope — use one session per execution scope", + ); + } + yield* Test.around({ sessionActive: () => true }); + + yield* installTestingVocabulary(options); + + const collected: TestResult[] = []; + yield* Test.around({ + // deno-lint-ignore require-yield + *results() { + return [...collected]; + }, + *record([result], next) { + collected.push(result); + yield* next(result); + }, + }); + + // Root activation — the same install performs for its subtree, + // applied to the whole execution (`xmd test` ≡ root ). + yield* Test.around({ testing: () => true }); + + // Completion policy: an otherwise successful execution becomes + // Err(TestFailureError) after its output closes when tests failed or none + // were discovered. A core Err passes through unchanged, and `results` + // stays available either way. + // + // One execute() per session: results are cumulative across the session, + // so a second document would inherit the first document's outcomes (a + // zero-test document after a passing one would succeed). Fail clearly + // BEFORE a handle exists — the pre-handle throw path. + let executed = false; + yield* Execution.around({ + *execute([executeOptions], next) { + if (executed) { + throw new Error( + "a useTesting() session supports one execute() call — start a new session for another document", + ); + } + executed = true; + const inner = yield* next(executeOptions); + return decorateCompletion(inner, () => { + if (collected.length === 0) { + return new TestFailureError("no tests were discovered"); + } + const failed = collected.filter((result) => result.status === "fail"); + if (failed.length > 0) { + const details = failed + .map( + (result) => + ` ${result.name ?? result.location}: ${result.error?.message ?? "failed"}`, + ) + .join("\n"); + return new TestFailureError( + `${failed.length} of ${collected.length} tests failed\n${details}`, + ); + } + return undefined; + }); + }, + }); + + return { + results: { + *[Symbol.iterator]() { + return Object.freeze([...collected]); + }, + }, + }; +} diff --git a/testing/src/vocabulary.ts b/testing/src/vocabulary.ts new file mode 100644 index 0000000..9cbaa38 --- /dev/null +++ b/testing/src/vocabulary.ts @@ -0,0 +1,128 @@ +/** + * Vocabulary registration (specs/testing-spec.md). + * + * Teaches the expansion loop the testing words — ``, ``, and + * the assertion components — via the core `expandInvocation` hook, and + * decorates the core Execution Api so explicit `` boundaries affect + * the execution outcome even when root testing is inactive. + * + * Registration is distinct from activation: installing the vocabulary + * leaves `testing` false, so `` skips and assertions stay usable. + * Root activation is `useTesting()`'s job. + * + * Installs are scope-local — call this inside a bounded scope (one CLI + * command invocation, one `scoped()` block). + */ + +import { Err } from "effection"; +import type { Operation } from "effection"; +import { Component, Execution } from "@executablemd/core"; +import type { DocumentExecution } from "@executablemd/core"; +import { boundary, record, Test, TestFailureError } from "./test-api.ts"; +import type { BoundaryOutcome } from "./test-api.ts"; +import { readCompletedRun } from "./journal.ts"; +import { ASSERTIONS } from "./assertions.ts"; +import { createTestHandlers } from "./handlers.ts"; +import type { TestHandlers } from "./handlers.ts"; + +const TEST_TIMEOUT_MS = 20_000; + +export function* installTestingVocabulary(options?: { verbose?: boolean }): Operation { + yield* installHandlers(createTestHandlers({ timeoutMs: TEST_TIMEOUT_MS }), options); +} + +/** + * Install a specific handler set. Internal seam: tests inject handlers built + * with a short timeout; the public path always uses the fixed 20 seconds. + */ +export function* installHandlers( + handlers: TestHandlers, + options?: { verbose?: boolean }, +): Operation { + if (options?.verbose) { + yield* Test.around({ verbose: () => true }); + } + yield* Component.around({ + *expandInvocation([invocation, ctx], next) { + if (invocation.name === "Testing") { + return { segments: yield* handlers.expandTesting(invocation, ctx) }; + } + if (invocation.name === "Test") { + return { segments: yield* handlers.expandTest(invocation, ctx) }; + } + const assertion = ASSERTIONS.get(invocation.name); + if (assertion) { + return { segments: yield* handlers.expandAssertion(assertion, invocation, ctx) }; + } + return yield* next(invocation, ctx); + }, + }); + yield* Execution.around({ + *execute([executeOptions], next) { + // Fresh boundary collection per execution: outcomes reported by + // explicit elements in THIS run decide this run's Result. + const boundaries: BoundaryOutcome[] = []; + yield* Test.around({ + *boundary([outcome], nextBoundary) { + boundaries.push(outcome); + yield* nextBoundary(outcome); + }, + }); + // Confirmed full replay: durableRun returns the stored root result + // without re-expanding, so nothing would re-record. Restore the + // journaled testing records into the current collectors — through + // the same record/boundary operations live expansion uses, so every + // session collector and observer sees them in discovery order. A + // live or partial journal (no root Close) hydrates nothing; + // re-expansion records each result exactly once via its durable + // operation. + const replayed = yield* readCompletedRun(executeOptions.stream); + if (replayed) { + for (const result of replayed.results) { + yield* record(result); + } + for (const outcome of replayed.boundaries) { + yield* boundary(outcome); + } + } + const inner = yield* next(executeOptions); + return decorateCompletion(inner, () => { + const failed = boundaries.filter((b) => b.failed > 0); + if (failed.length > 0) { + return new TestFailureError( + `${failed.reduce((n, b) => n + b.failed, 0)} test(s) failed in `, + ); + } + if (boundaries.some((b) => b.tests === 0)) { + return new TestFailureError("a boundary discovered no tests"); + } + return undefined; + }); + }, + }); +} + +/** + * Map an execution's completion: an `Ok` becomes `Err(failure())` when the + * policy reports one, after the inner completion — and therefore its closed + * output stream — settles. An existing `Err` passes through unchanged. + */ +export function decorateCompletion( + inner: DocumentExecution, + failure: () => Error | undefined, +): DocumentExecution { + return { + output: inner.output, + *[Symbol.iterator]() { + const result = yield* inner; + if (!result.ok) { + return result; + } + const error = failure(); + if (error) { + return Err(error); + } + return result; + }, + }; +} diff --git a/testing/tests/assertions.test.ts b/testing/tests/assertions.test.ts new file mode 100644 index 0000000..cf4c4e5 --- /dev/null +++ b/testing/tests/assertions.test.ts @@ -0,0 +1,198 @@ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { AssertionError } from "@std/assert"; +import { TestFailureError } from "../src/test-api.ts"; +import { failureOf, runDoc } from "./helpers.ts"; + +describe("assertion components", () => { + it("passing assertions in testing mode emit diagnostics", function* () { + const run = yield* runDoc( + { + "README.md": + '\n', + }, + { testing: false }, + ); + expect(run.completion.ok).toBe(true); + expect(run.output).toContain("**AssertEquals** passed"); + expect(run.results).toEqual([{ status: "pass", name: "eq", location: "README.md:1:10" }]); + }); + + it("assertions outside a test pass silently during regular execution", function* () { + const run = yield* runDoc({ + "README.md": "before\n\nafter\n", + }); + expect(run.completion.ok).toBe(true); + expect(run.output).not.toContain("AssertEquals"); + expect(run.output).toContain("before"); + expect(run.output).toContain("after"); + }); + + it("assertions outside a test emit diagnostics with verbose", function* () { + const run = yield* runDoc( + { "README.md": "\n" }, + { verbose: true }, + ); + expect(run.completion.ok).toBe(true); + expect(run.output).toContain("**AssertEquals** passed"); + }); + + it("a failed assertion outside a test aborts the document", function* () { + const run = yield* runDoc({ + "README.md": "before\n\nnever\n", + }); + const error = failureOf(run); + expect(error).toBeInstanceOf(AssertionError); + expect(run.output).not.toContain("never"); + // Diagnostics hidden without verbose — but the assertion still threw. + expect(run.output).not.toContain("AssertEquals"); + }); + + it("a failed assertion outside a test keeps its diagnostic with verbose", function* () { + const run = yield* runDoc( + { "README.md": "before\n\nnever\n" }, + { verbose: true }, + ); + expect(failureOf(run)).toBeInstanceOf(AssertionError); + expect(run.output).toContain("**AssertEquals** failed"); + expect(run.output).toContain("before"); + }); + + it("expected children behave like trimming", function* () { + const doc = [ + "", + '', + "Hello World", + "", + "", + "Hello World", + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.results.map((r) => r.status)).toEqual(["pass"]); + }); + + it("rejects both expected prop and expected children", function* () { + const doc = + "1\n"; + const run = yield* runDoc({ "README.md": doc }); + expect(failureOf(run)).toBeInstanceOf(TestFailureError); + expect(run.results[0]?.status).toBe("fail"); + expect(run.results[0]?.error?.kind).toBe("error"); + expect(run.results[0]?.error?.message).toContain("not both"); + }); + + it("rejects expected children on unary and numeric assertions", function* () { + const doc = + "x\n"; + const run = yield* runDoc({ "README.md": doc }); + expect(run.results[0]?.error?.message).toContain("expected children"); + }); + + it("match assertions require a real RegExp", function* () { + const doc = '\n'; + const run = yield* runDoc({ "README.md": doc }); + expect(run.results[0]?.status).toBe("fail"); + expect(run.results[0]?.error?.message).toContain("RegExp"); + }); + + it("match assertions accept a RegExp expression", function* () { + const doc = '\n'; + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.results[0]?.status).toBe("pass"); + }); + + it("unknown props are rejected per kind", function* () { + const doc = "\n"; + const run = yield* runDoc({ "README.md": doc }); + expect(run.results[0]?.error?.message).toContain('"actual"'); + }); + + it("missing required props are rejected", function* () { + const doc = "\n"; + const run = yield* runDoc({ "README.md": doc }); + expect(run.results[0]?.error?.message).toContain('"expected"'); + }); + + it("assertion expressions see live bindings from eval blocks", function* () { + const doc = [ + "", + "```js eval", + "const answer = { deep: [1, 2, 3] };", + "```", + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.results[0]?.status).toBe("pass"); + }); + + it("assertion expressions see caller bindings through projection", function* () { + const doc = [ + "```js eval", + 'const fromCaller = "outer-value";', + "```", + "", + '', + "", + "", + ].join("\n"); + const wrap = "projected: \n"; + const run = yield* runDoc({ "README.md": doc, "components/Wrap.md": wrap }); + expect(run.completion.ok).toBe(true); + expect(run.results[0]?.status).toBe("pass"); + }); + + it("formatter-visible toJSON/toString cannot change the outcome", function* () { + const doc = [ + "", + "```js eval", + "const cursed = { toJSON() { throw new Error('evil json'); }, toString() { throw new Error('evil string'); } };", + "```", + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.results[0]?.status).toBe("pass"); + expect(run.output).toContain("unformattable"); + }); + + it("a non-string msg is rejected by type check, never formatted", function* () { + // If msg were formatted before the assertion, the hostile toJSON or + // toString would throw and replace the validation outcome. + const doc = [ + "", + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(failureOf(run)).toBeInstanceOf(TestFailureError); + expect(run.results[0]?.status).toBe("fail"); + expect(run.results[0]?.error?.kind).toBe("error"); + expect(run.results[0]?.error?.message).toContain('"msg"'); + expect(run.results[0]?.error?.message).not.toContain("hostile-json"); + expect(run.results[0]?.error?.message).not.toContain("hostile-string"); + }); + + it("a throwing getter read at format time cannot change the outcome", function* () { + const doc = [ + "", + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.results[0]?.status).toBe("pass"); + expect(run.output).toContain("[object Object]"); + }); +}); diff --git a/testing/tests/cli.test.ts b/testing/tests/cli.test.ts new file mode 100644 index 0000000..52657b9 --- /dev/null +++ b/testing/tests/cli.test.ts @@ -0,0 +1,99 @@ +/** + * CLI integration tests for `xmd test` and `xmd run` (specs/testing-spec.md). + * + * Shells out to `deno run --allow-all cli/src/cli.ts` with piped stdio, so + * exit codes and report output are asserted TTY-independently. + */ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { timebox } from "@effectionx/timebox"; +import { spawn, each } from "effection"; +import type { Operation } from "effection"; +import { exec } from "@effectionx/process"; +import process from "node:process"; + +const TIMEOUT = 30_000; + +interface CliResult { + code: number | undefined; + stdout: string; + stderr: string; +} + +function cliEnv(): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + env[key] = value; + } + } + return env; +} + +function* runCli(args: string[]): Operation { + const result = yield* timebox(TIMEOUT, function* () { + const proc = yield* exec("deno", { + arguments: ["run", "--allow-all", "cli/src/cli.ts", ...args], + env: cliEnv(), + }); + + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const readStdout = yield* spawn(function* () { + for (const chunk of yield* each(proc.stdout)) { + stdoutChunks.push(new TextDecoder().decode(chunk)); + yield* each.next(); + } + }); + const readStderr = yield* spawn(function* () { + for (const chunk of yield* each(proc.stderr)) { + stderrChunks.push(new TextDecoder().decode(chunk)); + yield* each.next(); + } + }); + + const status = yield* proc.join(); + yield* readStdout; + yield* readStderr; + + return { + code: status.code, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + }; + }); + if (result.timeout) { + throw new Error("CLI subprocess timed out"); + } + return result.value; +} + +describe("xmd CLI", () => { + it("test exits 0 and prints the report when every test passes", function* () { + const result = yield* runCli(["test", "testing/tests/fixtures/passing.md"]); + expect(result.code).toBe(0); + expect(result.stdout).toContain("**AssertEquals** passed"); + expect(result.stdout).toContain("Regular content stays."); + }); + + it("test exits 1 and prints the failure diagnostic when a test fails", function* () { + const result = yield* runCli(["test", "testing/tests/fixtures/failing.md"]); + expect(result.code).toBe(1); + expect(result.stdout).toContain("**Assert** failed"); + expect(result.stdout).toContain("Test **bad** failed"); + expect(result.stderr).toContain("tests failed"); + }); + + it("test exits 1 when no tests are discovered", function* () { + const result = yield* runCli(["test", "README.md"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain("no tests were discovered"); + }); + + it("run skips tests entirely and exits 0", function* () { + const result = yield* runCli(["run", "testing/tests/fixtures/failing.md"]); + expect(result.code).toBe(0); + expect(result.stdout).not.toContain("Assert"); + expect(result.stdout).toContain("# Fixture"); + }); +}); diff --git a/testing/tests/fixtures/failing.md b/testing/tests/fixtures/failing.md new file mode 100644 index 0000000..cb1c815 --- /dev/null +++ b/testing/tests/fixtures/failing.md @@ -0,0 +1,4 @@ +# Fixture + + + diff --git a/testing/tests/fixtures/passing.md b/testing/tests/fixtures/passing.md new file mode 100644 index 0000000..a15f3c1 --- /dev/null +++ b/testing/tests/fixtures/passing.md @@ -0,0 +1,8 @@ +# Fixture + + +Hello World + + + +Regular content stays. diff --git a/testing/tests/helpers.ts b/testing/tests/helpers.ts new file mode 100644 index 0000000..84e3214 --- /dev/null +++ b/testing/tests/helpers.ts @@ -0,0 +1,94 @@ +/** + * Document-level test harness: compose the testing vocabulary (or a full + * useTesting() session) around core execute() inside a bounded scope, and + * observe chunks, close value, completion Result, and the delegated test + * results/boundaries. + */ + +import { scoped } from "effection"; +import type { Operation, Result } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableStream } from "@executablemd/durable-streams"; +import { useStubFs } from "@executablemd/runtime/test"; +import { execute } from "@executablemd/core"; +import { useTesting } from "../src/use-testing.ts"; +import { installHandlers, installTestingVocabulary } from "../src/vocabulary.ts"; +import type { TestHandlers } from "../src/handlers.ts"; +import { Test } from "../src/test-api.ts"; +import type { BoundaryOutcome, TestResult } from "../src/test-api.ts"; + +export interface DocRun { + /** Chunks received while streaming. */ + chunks: string[]; + /** The output stream's close value. */ + output: string; + completion: Result; + /** Results delegated past the session/vocabulary collectors. */ + results: TestResult[]; + boundaries: BoundaryOutcome[]; +} + +export interface RunDocOptions { + testing?: boolean; + verbose?: boolean; + docPath?: string; + /** Inject handlers (e.g. a short timeout) instead of the public set. */ + handlers?: TestHandlers; + /** Supply a journal stream (e.g. for replay scenarios). */ + stream?: DurableStream; +} + +export function* runDoc( + files: Record, + options: RunDocOptions = {}, +): Operation { + return yield* scoped(function* () { + yield* useStubFs(files); + + const results: TestResult[] = []; + const boundaries: BoundaryOutcome[] = []; + yield* Test.around({ + *record([result], next) { + results.push(result); + yield* next(result); + }, + *boundary([outcome], next) { + boundaries.push(outcome); + yield* next(outcome); + }, + }); + + if (options.handlers) { + yield* installHandlers(options.handlers, { verbose: options.verbose }); + if (options.testing) { + yield* Test.around({ testing: () => true }); + } + } else if (options.testing) { + yield* useTesting({ verbose: options.verbose }); + } else { + yield* installTestingVocabulary({ verbose: options.verbose }); + } + + const execution = yield* execute({ + docPath: options.docPath ?? "README.md", + stream: options.stream ?? new InMemoryStream(), + }); + + const chunks: string[] = []; + const output = yield* forEach(function* (chunk: string) { + chunks.push(chunk); + }, execution.output); + + const completion = yield* execution; + + return { chunks, output, completion, results, boundaries }; + }); +} + +export function failureOf(run: DocRun): Error | undefined { + if (run.completion.ok) { + return undefined; + } + return run.completion.error; +} diff --git a/testing/tests/replay.test.ts b/testing/tests/replay.test.ts new file mode 100644 index 0000000..c25e966 --- /dev/null +++ b/testing/tests/replay.test.ts @@ -0,0 +1,147 @@ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { API } from "@executablemd/runtime"; +import { TestFailureError } from "../src/test-api.ts"; +import { failureOf, runDoc } from "./helpers.ts"; +import type { DocRun, RunDocOptions } from "./helpers.ts"; +import type { Operation } from "effection"; + +interface LiveAndReplay { + live: DocRun; + replay: DocRun; + replayStream: InMemoryStream; +} + +/** + * Run a document live on a fresh journal, then run it again on a copy of + * the completed journal — a confirmed full replay. + */ +function* liveThenReplay( + files: Record, + options: RunDocOptions = {}, +): Operation { + const liveStream = new InMemoryStream(); + const live = yield* runDoc(files, { ...options, stream: liveStream }); + const events = yield* liveStream.readAll(); + const replayStream = new InMemoryStream(events); + const replay = yield* runDoc(files, { ...options, stream: replayStream }); + return { live, replay, replayStream }; +} + +describe("testing replay", () => { + it("a passing useTesting run preserves its outcome and results on full replay", function* () { + const { live, replay, replayStream } = yield* liveThenReplay( + { "README.md": '\n' }, + { testing: true }, + ); + expect(live.completion.ok).toBe(true); + expect(replay.completion.ok).toBe(true); + expect(replay.results).toEqual(live.results); + expect(replay.results.map((r) => [r.name, r.status])).toEqual([["one", "pass"]]); + // Full replay appends nothing to the journal. + expect(replayStream.appendCount).toBe(0); + }); + + it("a failing useTesting run stays failed on full replay, with results available", function* () { + const { live, replay } = yield* liveThenReplay( + { "README.md": '\n' }, + { testing: true }, + ); + expect(failureOf(live)).toBeInstanceOf(TestFailureError); + expect(failureOf(replay)).toBeInstanceOf(TestFailureError); + expect(replay.results).toEqual(live.results); + expect(replay.results.map((r) => [r.name, r.status, r.error?.kind])).toEqual([ + ["bad", "fail", "assertion"], + ]); + }); + + it("a zero-test useTesting run stays failed on full replay", function* () { + const { live, replay } = yield* liveThenReplay( + { "README.md": "just text\n" }, + { testing: true }, + ); + expect(failureOf(live)?.message).toContain("no tests were discovered"); + expect(failureOf(replay)?.message).toContain("no tests were discovered"); + expect(replay.results).toEqual([]); + }); + + it("explicit boundaries preserve pass, fail, and empty outcomes on replay", function* () { + const passing = yield* liveThenReplay({ + "README.md": "\n", + }); + expect(passing.live.completion.ok).toBe(true); + expect(passing.replay.completion.ok).toBe(true); + expect(passing.replay.boundaries).toEqual([{ tests: 1, failed: 0 }]); + + const failing = yield* liveThenReplay({ + "README.md": "\n", + }); + expect(failureOf(failing.live)).toBeInstanceOf(TestFailureError); + expect(failureOf(failing.replay)).toBeInstanceOf(TestFailureError); + expect(failing.replay.boundaries).toEqual([{ tests: 1, failed: 1 }]); + + const empty = yield* liveThenReplay({ + "README.md": "no tests\n", + }); + expect(failureOf(empty.live)).toBeInstanceOf(TestFailureError); + expect(failureOf(empty.replay)).toBeInstanceOf(TestFailureError); + expect(empty.replay.boundaries).toEqual([{ tests: 0, failed: 0 }]); + }); + + it("partial replay records each result exactly once, in discovery order", function* () { + const files = { + "README.md": [ + '', + '', + "", + ].join("\n"), + }; + const liveStream = new InMemoryStream(); + const live = yield* runDoc(files, { testing: true, stream: liveStream }); + expect(live.completion.ok).toBe(true); + + // Drop the root Close: the journal is now a partial trace, so the + // document re-expands with completed durable records replaying in place. + const events = yield* liveStream.readAll(); + const partial = events.filter((event: DurableEvent) => event.type !== "close"); + const resumed = yield* runDoc(files, { + testing: true, + stream: new InMemoryStream(partial), + }); + expect(resumed.completion.ok).toBe(true); + expect(resumed.results.map((r) => [r.name, r.status])).toEqual([ + ["first", "pass"], + ["second", "pass"], + ]); + }); + + it("full replay leaves output unchanged and reruns no document effects", function* () { + const execCalls: string[] = []; + yield* API.Process.around({ + // deno-lint-ignore require-yield + *exec([options], _next) { + execCalls.push(options.command.join(" ")); + return { exitCode: 0, stdout: "ran\n", stderr: "" }; + }, + }); + const files = { + "README.md": [ + "", + "```bash exec", + "echo hi", + "```", + "", + "", + "", + ].join("\n"), + }; + const { live, replay, replayStream } = yield* liveThenReplay(files); + expect(live.completion.ok).toBe(true); + expect(replay.completion.ok).toBe(true); + expect(execCalls).toHaveLength(1); + expect(replay.output).toBe(live.output); + expect(replayStream.appendCount).toBe(0); + }); +}); diff --git a/testing/tests/smoke.test.ts b/testing/tests/smoke.test.ts new file mode 100644 index 0000000..37faa41 --- /dev/null +++ b/testing/tests/smoke.test.ts @@ -0,0 +1,88 @@ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { scoped } from "effection"; +import type { Operation, Result } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { execute } from "@executablemd/core"; +import type { ExecuteOptions } from "@executablemd/core"; +import { useTesting } from "../src/use-testing.ts"; +import type { TestResult } from "../src/test-api.ts"; + +const EMBEDDED_TESTS = [ + "Root frontmatter interpolates into the heading", + "Root frontmatter interpolates into prose", + "Note renders its default level", + "Note renders an overridden level", + "Section renders children through its Content slot", + "Feature expands its nested Note", + "Dotted component names resolve to directory paths", + "exec renders command stdout", + "silent exec suppresses rendered output", + "Non-executable code blocks pass through verbatim", + "Props interpolate into the component body", + "Expression props resolve from eval bindings", + "JSON literal props resolve at scan time", + "Non-string bindings coerce in text", + "Eval bindings resolve in prose text", + "Meta and eval bindings share the same text", + "Escaped braces stay literal", + "Unresolved references pass through verbatim", + "Component as-capture binds without rendering inline", + "Capture binds inline content", + "Capture select extracts the matching node", + "Unclosed bold heals at a component boundary", + "Eval blocks render no output", + "Eval blocks share bindings", + "Persist keeps spawned tasks alive across blocks", + "Timeout-bounded eval blocks complete", + "findFreePort allocates a free port", + "Eval bindings interpolate into exec blocks", + "A daemon serves requests until its scope closes", + "Sample sends its prompt to the provider", + "Sample consumes children as content", + "Named slots place content in their regions", + "Named slots compose with the default slot", + "Instruction sets the provider system prompt", + "Exec output is journaled with the run timestamp", + "Output regions render only the selected region", +]; + +interface SmokeSession { + result: Result; + results: readonly TestResult[]; +} + +function* runSmokeSession(options: ExecuteOptions): Operation { + return yield* scoped(function* () { + const tests = yield* useTesting(); + const execution = yield* execute(options); + const result = yield* execution; + return { result, results: yield* tests.results }; + }); +} + +describe("smoke test", { sanitizeOps: false, sanitizeResources: false }, () => { + it("smoke document passes its embedded tests live and on replay", function* () { + const stream = new InMemoryStream(); + const options: ExecuteOptions = { + docPath: "smoke-test/README.md", + stream, + componentDirs: ["smoke-test", "core/components"], + }; + + const live = yield* runSmokeSession(options); + if (!live.result.ok) { + throw live.result.error; + } + expect(live.results.map((entry) => [entry.name, entry.status])).toEqual( + EMBEDDED_TESTS.map((name) => [name, "pass"]), + ); + expect(stream.snapshot().length).toBeGreaterThan(10); + const liveAppendCount = stream.appendCount; + + const replay = yield* runSmokeSession(options); + expect(replay.result).toEqual(live.result); + expect(replay.results).toEqual(live.results); + expect(stream.appendCount).toBe(liveAppendCount); + }); +}); diff --git a/testing/tests/testing-mode.test.ts b/testing/tests/testing-mode.test.ts new file mode 100644 index 0000000..9ac646c --- /dev/null +++ b/testing/tests/testing-mode.test.ts @@ -0,0 +1,320 @@ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { sleep } from "effection"; +import { API } from "@executablemd/runtime"; +import { useFailingExec } from "@executablemd/runtime/test"; +import { TestFailureError } from "../src/test-api.ts"; +import { createTestHandlers } from "../src/handlers.ts"; +import { failureOf, runDoc } from "./helpers.ts"; + +describe("testing mode", () => { + it("skips entirely during regular execution", function* () { + const execCalls: string[] = []; + yield* API.Process.around({ + // deno-lint-ignore require-yield + *exec([options], _next) { + execCalls.push(options.command.join(" ")); + return { exitCode: 0, stdout: "ran\n", stderr: "" }; + }, + }); + const doc = [ + "before", + "", + "```bash exec", + "echo hi", + "```", + "test body text", + "", + "after", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.output).toContain("before"); + expect(run.output).toContain("after"); + expect(run.output).not.toContain("test body text"); + expect(execCalls).toEqual([]); + expect(run.results).toEqual([]); + }); + + it("explicit runs its subtree during a regular run", function* () { + const doc = '\n'; + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.results.map((r) => [r.name, r.status])).toEqual([["t", "pass"]]); + expect(run.boundaries).toEqual([{ tests: 1, failed: 0 }]); + }); + + it("a failing boundary rejects an ordinary run", function* () { + const doc = "\n"; + const run = yield* runDoc({ "README.md": doc }); + expect(failureOf(run)).toBeInstanceOf(TestFailureError); + expect(run.output).toContain("Test"); + }); + + it("an empty boundary rejects an ordinary run", function* () { + const run = yield* runDoc({ "README.md": "no tests here\n" }); + expect(failureOf(run)).toBeInstanceOf(TestFailureError); + expect(run.boundaries).toEqual([{ tests: 0, failed: 0 }]); + }); + + it("root activation runs tests without ", function* () { + const doc = '\n'; + const run = yield* runDoc({ "README.md": doc }, { testing: true }); + expect(run.completion.ok).toBe(true); + expect(run.results.map((r) => [r.name, r.status])).toEqual([["rooted", "pass"]]); + }); + + it("root activation with zero tests fails", function* () { + const run = yield* runDoc({ "README.md": "just text\n" }, { testing: true }); + const error = failureOf(run); + expect(error).toBeInstanceOf(TestFailureError); + expect(error?.message).toContain("no tests were discovered"); + }); + + it("nested delegates results outward", function* () { + const doc = [ + "", + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + // Inner boundary reports first; the outer boundary sees the delegated + // result, so neither boundary is empty. + expect(run.boundaries).toEqual([ + { tests: 1, failed: 0 }, + { tests: 1, failed: 0 }, + ]); + expect(run.results).toHaveLength(1); + }); + + it("a failing exec block fails the test; later tests run", function* () { + yield* useFailingExec(3, "command exploded"); + const doc = [ + "", + '', + "```bash exec", + "false", + "```", + "", + '', + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(failureOf(run)).toBeInstanceOf(TestFailureError); + expect(run.results.map((r) => [r.name, r.status, r.error?.kind])).toEqual([ + ["broken", "fail", "error"], + ["fine", "pass", undefined], + ]); + }); + + it("an unresolvable component import fails the test; later tests run", function* () { + const doc = [ + "", + '', + '', + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.results.map((r) => [r.name, r.status, r.error?.kind])).toEqual([ + ["missing", "fail", "error"], + ["fine", "pass", undefined], + ]); + }); + + it("nested fails the enclosing test; later tests run", function* () { + const doc = [ + "", + '', + '', + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.results.map((r) => [r.name, r.status, r.error?.kind])).toEqual([ + ["outer", "fail", "error"], + ["fine", "pass", undefined], + ]); + expect(run.results[0]?.error?.message).toContain("Nested "); + }); + + it("an error raised inside a component-owned region fails the test", function* () { + const comp = "\nregion start\n\n\n"; + const doc = [ + "", + '', + '', + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc, "components/Regioned.md": comp }); + expect(run.results.map((r) => [r.name, r.status])).toEqual([ + ["output-region", "fail"], + ["fine", "pass"], + ]); + }); + + it("a completed failing test inside a documentation region stays contained", function* () { + const comp = "\n\n\n"; + const doc = [ + "", + '', + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc, "components/Regioned.md": comp }); + // The document expands fully — the failure surfaces as a testing + // outcome, not an expansion abort. + expect(failureOf(run)).toBeInstanceOf(TestFailureError); + expect(run.results.map((r) => [r.name, r.status])).toEqual([["contained", "fail"]]); + }); + + it("bindings written in one test are invisible to the next", function* () { + const doc = [ + "```js eval", + "const inherited = 7;", + "```", + "", + '', + "```js eval", + "const leak = 1;", + "```", + 'captured', + "", + "", + '', + '', + "", + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.results.map((r) => r.status)).toEqual(["pass", "pass"]); + }); + + it("a projected through sees caller bindings in its eval blocks", function* () { + const doc = [ + "```js eval", + 'const callerValue = "from-caller";', + "```", + "", + '', + "```js eval", + 'const copied = callerValue + "!";', + "```", + '', + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc, "components/Wrap.md": "\n" }); + expect(run.completion.ok).toBe(true); + expect(run.results.map((r) => [r.name, r.status])).toEqual([["projected", "pass"]]); + }); + + it("captures persist for the immediately following assertion", function* () { + const doc = [ + "", + 'Hello World', + '', + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.results.map((r) => r.status)).toEqual(["pass"]); + }); + + it("effects spawned in one test are torn down before the next begins", function* () { + Reflect.deleteProperty(globalThis, "__testingLeaseAlive"); + const doc = [ + "", + '', + "```js persist eval", + "globalThis.__testingLeaseAlive = true;", + "yield* spawn(function* () { try { yield* suspend(); } finally { globalThis.__testingLeaseAlive = false; } });", + "```", + "", + "", + '', + "", + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.completion.ok).toBe(true); + expect(run.results.map((r) => [r.name, r.status])).toEqual([ + ["spawner", "pass"], + ["observer", "pass"], + ]); + }); + + it("output before a failure is kept, followed by the diagnostic", function* () { + const doc = [ + "", + "first line", + "", + "never rendered", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.output).toContain("first line"); + expect(run.output).toContain("**Assert** failed"); + expect(run.output).not.toContain("never rendered"); + const firstAt = run.output.indexOf("first line"); + const diagnosticAt = run.output.indexOf("**Assert** failed"); + expect(firstAt).toBeLessThan(diagnosticAt); + }); + + it("a hanging test times out, is torn down, and later tests run", function* () { + yield* API.Process.around({ + *exec(_args, _next) { + yield* sleep(10_000); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }); + const doc = [ + "", + '', + "```bash exec", + "sleep 60", + "```", + "", + '', + "", + "", + ].join("\n"); + const run = yield* runDoc( + { "README.md": doc }, + { handlers: createTestHandlers({ timeoutMs: 100 }) }, + ); + expect(failureOf(run)).toBeInstanceOf(TestFailureError); + expect(run.results.map((r) => [r.name, r.status, r.error?.kind])).toEqual([ + ["hangs", "fail", "timeout"], + ["fine", "pass", undefined], + ]); + }); + + it("unnamed tests are identified by source location", function* () { + const doc = [ + "---", + "title: Located", + "---", + "", + "", + "", + "", + ].join("\n"); + const run = yield* runDoc({ "README.md": doc }); + expect(run.results[0]?.location).toBe("README.md:5:1"); + expect(run.output).toContain("test at README.md:5:1"); + }); +}); diff --git a/testing/tests/use-testing.test.ts b/testing/tests/use-testing.test.ts new file mode 100644 index 0000000..7ad443c --- /dev/null +++ b/testing/tests/use-testing.test.ts @@ -0,0 +1,214 @@ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { scoped, sleep, spawn } from "effection"; +import type { Operation, Subscription } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { useStubFs } from "@executablemd/runtime/test"; +import { API } from "@executablemd/runtime"; +import { execute, Execution } from "@executablemd/core"; +import { Test, TestFailureError, testing } from "../src/test-api.ts"; +import { useTesting } from "../src/use-testing.ts"; +import { failureOf, runDoc } from "./helpers.ts"; + +function* drain( + subscription: Subscription, +): Operation<{ chunks: string[]; close: string }> { + const chunks: string[] = []; + let next = yield* subscription.next(); + while (!next.done) { + chunks.push(next.value); + next = yield* subscription.next(); + } + return { chunks, close: next.value }; +} + +describe("useTesting composition", () => { + it("composes around core execute and returns session results", function* () { + const outcome = yield* scoped(function* () { + yield* useStubFs({ + "README.md": '\n', + }); + const tests = yield* useTesting(); + const execution = yield* execute({ docPath: "README.md", stream: new InMemoryStream() }); + const result = yield* execution; + const results = yield* tests.results; + return { result, results }; + }); + expect(outcome.result.ok).toBe(true); + expect(outcome.results.map((r) => [r.name, r.status])).toEqual([["one", "pass"]]); + expect(Object.isFrozen(outcome.results)).toBe(true); + }); + + it("keeps results available after a testing failure", function* () { + const outcome = yield* scoped(function* () { + yield* useStubFs({ + "README.md": '\n', + }); + const tests = yield* useTesting(); + const execution = yield* execute({ docPath: "README.md", stream: new InMemoryStream() }); + const result = yield* execution; + const results = yield* tests.results; + return { result, results }; + }); + expect(outcome.result.ok).toBe(false); + if (!outcome.result.ok) { + expect(outcome.result.error).toBeInstanceOf(TestFailureError); + } + expect(outcome.results.map((r) => [r.name, r.status])).toEqual([["bad", "fail"]]); + }); + + it("preserves a core Err unchanged", function* () { + const result = yield* scoped(function* () { + yield* useStubFs({}); + yield* useTesting(); + const execution = yield* execute({ docPath: "missing.md", stream: new InMemoryStream() }); + return yield* execution; + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).not.toBeInstanceOf(TestFailureError); + expect(result.error.message).toContain("missing.md"); + } + }); + + it("enforces one execute() call per session", function* () { + // Results are cumulative across a session: without the guard, a + // zero-test second document would succeed on the strength of the first + // document's passing test. + let thrown: Error | undefined; + const first = yield* scoped(function* () { + yield* useStubFs({ + "README.md": '\n', + "empty.md": "no tests here\n", + }); + yield* useTesting(); + const execution = yield* execute({ docPath: "README.md", stream: new InMemoryStream() }); + const result = yield* execution; + try { + yield* execute({ docPath: "empty.md", stream: new InMemoryStream() }); + } catch (failure) { + thrown = failure instanceof Error ? failure : new Error(String(failure)); + } + return result; + }); + expect(first.ok).toBe(true); + expect(thrown?.message).toContain("one execute() call"); + }); + + it("enforces one session per execution scope", function* () { + let error: Error | undefined; + yield* scoped(function* () { + yield* useTesting(); + try { + yield* useTesting(); + } catch (failure) { + error = failure instanceof Error ? failure : new Error(String(failure)); + } + }); + expect(error?.message).toContain("one session per execution scope"); + }); + + it("removes all session middleware with its scope", function* () { + yield* scoped(function* () { + yield* useStubFs({ "README.md": "\n" }); + yield* useTesting(); + const execution = yield* execute({ docPath: "README.md", stream: new InMemoryStream() }); + const result = yield* execution; + expect(result.ok).toBe(true); + }); + + // Outside the session scope: activation, collectors, and policy are gone. + expect(yield* testing).toBe(false); + expect(yield* Test.operations.results()).toEqual([]); + const second = yield* runDoc({ "README.md": "\n" }); + expect(second.completion.ok).toBe(true); + expect(second.output).not.toContain("Assert"); + expect(second.results).toEqual([]); + }); + + it("completion returns Err instead of throwing for document failures", function* () { + const run = yield* runDoc({}, { docPath: "missing.md" }); + const error = failureOf(run); + expect(error?.message).toContain("missing.md"); + expect(run.output).toBe(""); + }); + + it("a failure before the handle exists may still throw", function* () { + let thrown: Error | undefined; + yield* scoped(function* () { + yield* useStubFs({ "README.md": "hello\n" }); + yield* Execution.around({ + // deno-lint-ignore require-yield + *execute() { + throw new Error("pre-handle setup exploded"); + }, + }); + try { + yield* execute({ docPath: "README.md", stream: new InMemoryStream() }); + } catch (failure) { + thrown = failure instanceof Error ? failure : new Error(String(failure)); + } + }); + expect(thrown?.message).toBe("pre-handle setup exploded"); + }); + + it("a late subscriber still receives all chunks and the close value", function* () { + const { late, value } = yield* scoped(function* () { + yield* useStubFs({ "README.md": "hello world\n" }); + yield* useTesting(); + const execution = yield* execute({ + docPath: "README.md", + stream: new InMemoryStream(), + }); + const result = yield* execution; + const drained = yield* scoped(function* () { + return yield* drain(yield* execution.output); + }); + return { late: drained, value: result }; + }); + // No tests in the doc — the session rejects, but output is intact. + expect(value.ok).toBe(false); + expect(late.close).toContain("hello world"); + expect(late.chunks.join("")).toContain("hello world"); + }); + + it("an early caller-scope halt tears down the document and leased eval scope", function* () { + Reflect.deleteProperty(globalThis, "__useTestingHaltMarker"); + yield* API.Process.around({ + *exec(_args, _next) { + yield* sleep(10_000); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }); + const doc = [ + "", + '', + "```js persist eval", + "globalThis.__useTestingHaltMarker = true;", + "yield* spawn(function* () { try { yield* suspend(); } finally { globalThis.__useTestingHaltMarker = false; } });", + "```", + "```bash exec", + "sleep 600", + "```", + "", + "", + "", + ].join("\n"); + yield* useStubFs({ "README.md": doc }); + + yield* scoped(function* () { + yield* useTesting(); + const execution = yield* execute({ docPath: "README.md", stream: new InMemoryStream() }); + yield* spawn(function* () { + yield* scoped(function* () { + yield* drain(yield* execution.output); + }); + }); + // Give the document time to start the test and spawn its effect, + // then leave the scope — halting everything mid-test. + yield* sleep(200); + }); + + expect(Reflect.get(globalThis, "__useTestingHaltMarker")).toBe(false); + }); +});