diff --git a/packages/cli/tests/evaluate-workflow.test.ts b/packages/cli/tests/evaluate-workflow.test.ts
index 5b24f0b0e..061d2747d 100644
--- a/packages/cli/tests/evaluate-workflow.test.ts
+++ b/packages/cli/tests/evaluate-workflow.test.ts
@@ -9,14 +9,11 @@
* What only this tier can show is that the two hosts differ in their *profile*
* and in nothing else. The component, the evaluator and the durable protocol
* are the same ones the ordinary run uses; what the workflow adds is a
- * Workspace-bound ceiling, a basis that advances with the run, and the released
- * `source` spelling — which the ordinary profile refuses and this one accepts
- * without comment.
+ * Workspace-bound ceiling and a basis that advances with the run. Every host
+ * accepts the canonical `text` input and refuses `source` and `program`.
*
* It runs against the real attachment rather than the `xmd workflow` command
- * line: a run started from the command boundary needs a git repository with a
- * committed definition, and a row that silently degraded when that was
- * unavailable would assert nothing. The command boundary itself is Tier WAL's.
+ * line, whose start/resume behavior is exercised separately by Tier WAL.
*/
import { describe, it } from "@executablemd/test-support/bdd";
@@ -126,23 +123,30 @@ describe("Tier FE — the workflow host's profile", () => {
expect(Reflect.get(entry, "capability")).toBe("directory:ensure");
});
- it("FE19: `source` and `text` behave identically here, and neither warns", function* () {
+ it("FE19: canonical `text` repeats across fresh runs without warnings", function* () {
+ let reads = 0;
+ yield* API.Files.around({
+ *readTextFile(args, next) {
+ reads++;
+ return yield* next(...args);
+ },
+ });
const root = yield* useStorageRoot();
yield* withStorage(root, function* () {
- const aliased = yield* createRun();
- const alias = yield* runDocument(
- aliased,
+ const first = yield* createRun();
+ const initial = yield* runDocument(
+ first,
`${NOTE}\n\n` +
`\\n'} as="answer" />\n\n` +
`\n`,
);
- expect(alias.failure).toBe(undefined);
- const aliasText = String(alias.output);
- expect(aliasText).toContain(NOTE);
+ expect(initial.failure).toBe(undefined);
+ const initialText = String(initial.output);
+ expect(initialText).toContain(NOTE);
// Silently: no deprecation sentence anywhere in the run's output.
- expect(aliasText).not.toContain("earlier spelling");
- expect(aliasText).not.toContain("deprecated");
+ expect(initialText).not.toContain("earlier spelling");
+ expect(initialText).not.toContain("deprecated");
const canonical = yield* createRun({ runId: "canonical" });
const direct = yield* runDocument(
@@ -153,24 +157,35 @@ describe("Tier FE — the workflow host's profile", () => {
);
expect(direct.failure).toBe(undefined);
- // Identical behavior: the two spellings state the same argument.
- expect(String(direct.output)).toBe(aliasText);
+ expect(String(direct.output)).toBe(initialText);
+ expect(reads).toBe(2);
});
});
- it("FE19: `program` is a spelling no profile has", function* () {
- const root = yield* useStorageRoot();
- yield* withStorage(root, function* () {
- const database = yield* createRun();
- const attempt = yield* runDocument(
- database,
- `\\n'} />\n`,
- );
-
- expect(attempt.failure).not.toBe(undefined);
- expect(String(attempt.failure)).toMatch(/program|additional/i);
+ for (const prop of ["source", "program"]) {
+ it(`FE19: obsolete \`${prop}\` refuses before fragment effects`, function* () {
+ let reads = 0;
+ yield* API.Files.around({
+ *readTextFile(args, next) {
+ reads++;
+ return yield* next(...args);
+ },
+ });
+ const root = yield* useStorageRoot();
+ yield* withStorage(root, function* () {
+ const database = yield* createRun();
+ const attempt = yield* runDocument(
+ database,
+ `${NOTE}\n\n` +
+ `\\n'} />\n`,
+ );
+
+ expect(attempt.failure).not.toBe(undefined);
+ expect(String(attempt.failure)).toMatch(new RegExp(`${prop}|additional`, "i"));
+ expect(reads).toBe(0);
+ });
});
- });
+ }
it("FE21: the workflow fragment reaches the run's Workspace, not the caller's disk", function* () {
const root = yield* useStorageRoot();
diff --git a/packages/cli/tests/repl-execution.test.ts b/packages/cli/tests/repl-execution.test.ts
index fe9a1369e..af072270f 100644
--- a/packages/cli/tests/repl-execution.test.ts
+++ b/packages/cli/tests/repl-execution.test.ts
@@ -717,21 +717,20 @@ describe("REPL execution: pausing expansion", () => {
it("P1: tearing the owner down releases nothing that was held", function* () {
const holder = execution();
const source = yield* referenceSource();
- let released = -1;
let held = -1;
- yield* scoped(function* () {
+ const controller = yield* scoped(function* () {
const session = opened(yield* submitReplEntry({ ...options(holder), source }));
controlling(session).pause();
yield* pausedOrDone(session);
expect(session.expansion.state).toBe("paused");
- released = controlling(session).released;
held = (yield* readAll(holder)).length;
+ return controlling(session);
});
// The scope is gone: every hold unwound through its own discard, so nothing
// was resumed on the way out and the history stopped where it was held.
- expect(released).toBe(0);
+ expect(controller.released).toBe(0);
expect((yield* readAll(holder)).length).toBe(held);
});
diff --git a/packages/core/tests/expand.test.ts b/packages/core/tests/expand.test.ts
index 3262f09e3..32b978543 100644
--- a/packages/core/tests/expand.test.ts
+++ b/packages/core/tests/expand.test.ts
@@ -1,6 +1,6 @@
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
-import { scoped, sleep, spawn, withResolvers } from "effection";
+import { ensure, scoped, sleep, spawn, withResolvers } from "effection";
import type { Subscription } from "effection";
import { createBlockCounter, expandSegments } from "../src/expand.ts";
import { regionStream } from "../src/expansion-region.ts";
@@ -23,7 +23,7 @@ import { interpolate } from "../src/interpolate.ts";
import { validateProps, PropValidationError } from "../src/validate.ts";
import { renderSegments } from "../src/render.ts";
import type { Operation } from "effection";
-import { DocumentationError } from "../src/errors.ts";
+import { DocumentationError, useSegmentCauses } from "../src/errors.ts";
import type {
ComponentElement,
Segment,
@@ -1525,6 +1525,8 @@ describe("Tier SR — expanding installed structural syntax", () => {
it("SR8: a handler's return halts and joins a blocked producer", function* () {
const order: string[] = [];
+ const cleanupStarted = withResolvers();
+ const cleanupAllowed = withResolvers();
const environment = yield* installedEnvironment(
[structuralDeclaration(), regionDeclaration()],
function* (request) {
@@ -1533,12 +1535,15 @@ describe("Tier SR — expanding installed structural syntax", () => {
throw new Error("expected a region");
}
const subscription = yield* yield* region.expand();
- yield* subscription.next();
+ expect(yield* readText(subscription)).toBe("step 1");
order.push("handler returning");
},
);
yield* scoped(function* () {
+ // A real execution already owns this table. Keeping it outside the
+ // occurrence prevents direct expansion's fallback scope masking a leak.
+ yield* useSegmentCauses();
yield* useTestComponents({
Step: {
kind: "function",
@@ -1551,17 +1556,48 @@ describe("Tier SR — expanding installed structural syntax", () => {
},
},
});
- yield* expandInstalled(
- '\n \n\n\n\n',
- environment,
+ yield* Component.around(
+ {
+ *applyModifiers() {
+ // A modifier runs in the producer's scope, so this cleanup survives
+ // the first chunk and is joined when the handler returns.
+ yield* ensure(function* () {
+ order.push("cleanup started");
+ cleanupStarted.resolve();
+ yield* cleanupAllowed.operation;
+ order.push("cleanup finished");
+ });
+ order.push("ran:1");
+ return { output: "step 1", exitCode: 0, stderr: "" };
+ },
+ },
+ { at: "min" },
);
- order.push("occurrence settled");
+ try {
+ const occurrence = yield* spawn(function* () {
+ yield* expandInstalled(
+ '\n \n```text exec\nfirst\n```\n\n' +
+ "\n\n",
+ environment,
+ );
+ order.push("occurrence settled");
+ });
+ yield* cleanupStarted.operation;
+ expect(order).toEqual(["ran:1", "handler returning", "cleanup started"]);
+ cleanupAllowed.resolve();
+ yield* occurrence;
+ } finally {
+ cleanupAllowed.resolve();
+ }
});
- // The producer was blocked on demand when the handler returned. It is
- // halted with the handler's scope, so the second segment never ran and the
- // occurrence settled after the handler rather than racing it.
- expect(order).toEqual(["ran:1", "handler returning", "occurrence settled"]);
+ expect(order).toEqual([
+ "ran:1",
+ "handler returning",
+ "cleanup started",
+ "cleanup finished",
+ "occurrence settled",
+ ]);
});
it("SR9: a failing body delivers its prefix, then raises the original failure", function* () {
@@ -1615,7 +1651,7 @@ describe("Tier SR — expanding installed structural syntax", () => {
expect(raised).toBe(failure);
});
- it("SR10: each expansion and each subscription is its own", function* () {
+ it("SR10: each expansion call owns its producer and output", function* () {
const runs: string[] = [];
let first: string[] = [];
let second: string[] = [];
@@ -1656,6 +1692,49 @@ describe("Tier SR — expanding installed structural syntax", () => {
expect(second.join("")).toContain("run 2");
});
+ it("SR10: two subscriptions to one returned stream own their producers and output", function* () {
+ const runs: string[] = [];
+ let first = "";
+ let second = "";
+ const environment = yield* installedEnvironment(
+ [structuralDeclaration(), regionDeclaration()],
+ function* (request) {
+ const [region] = request.regions;
+ if (region === undefined) {
+ throw new Error("expected a region");
+ }
+ const stream = yield* region.expand();
+ const firstSubscription = yield* stream;
+ const secondSubscription = yield* stream;
+ first = yield* readText(firstSubscription);
+ second = yield* readText(secondSubscription);
+ },
+ );
+
+ yield* scoped(function* () {
+ yield* useTestComponents({
+ Count: {
+ kind: "function",
+ name: "Count",
+ props: { type: "object", properties: {}, additionalProperties: false },
+ // deno-lint-ignore require-yield
+ *fn() {
+ runs.push("ran");
+ return `run ${String(runs.length)}`;
+ },
+ },
+ });
+ yield* expandInstalled(
+ '\n \n\n',
+ environment,
+ );
+ });
+
+ expect(runs).toHaveLength(2);
+ expect(first).toBe("run 1");
+ expect(second).toBe("run 2");
+ });
+
it("SR11: without an environment, and for the engine's own syntax, nothing changed", function* () {
// No environment at all: the name is an ordinary component lookup.
const rendered = yield* scoped(function* () {
diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts
index 13057e7aa..fd0a8c93e 100644
--- a/scripts/runtime-test-exclusions.ts
+++ b/scripts/runtime-test-exclusions.ts
@@ -476,7 +476,7 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [
{
path: "packages/cli/tests/evaluate-workflow.test.ts",
reason:
- "proves the workflow evaluation profile — its Workspace-bound ceiling, its advancing basis and its released `source` spelling — against a real node:sqlite WorkflowRun database with the Deno DOFS Workspace attached. The claim is that this host's profile differs from the ordinary one and nothing else does, so a substituted store would be a substitute for the subject; Bun has no node:sqlite and Node 22 keeps it behind --experimental-sqlite",
+ "proves the workflow evaluation profile — its Workspace-bound ceiling, its advancing basis and its canonical `text` input with obsolete-prop refusal — against a real node:sqlite WorkflowRun database with the Deno DOFS Workspace attached. The claim is that this host's profile differs from the ordinary one and nothing else does, so a substituted store would be a substitute for the subject; Bun has no node:sqlite and Node 22 keeps it behind --experimental-sqlite",
issue: "https://github.com/taras/executable.md/issues/713",
},
{
diff --git a/scripts/tests/release-targets.test.ts b/scripts/tests/release-targets.test.ts
index 171ac3be9..15616cc14 100644
--- a/scripts/tests/release-targets.test.ts
+++ b/scripts/tests/release-targets.test.ts
@@ -28,14 +28,12 @@ const ATTEST_ACTION = "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6";
* is either a job that cannot prepare or a mapping nothing uses.
*/
function* matrixTargets(): Operation {
- const workflow = yield* readTextFile(RELEASE_WORKFLOW);
- return [...workflow.matchAll(/^\s+- target:\s*(\S+)/gm)].map((match) => match[1] ?? "");
+ return (yield* releaseWorkflow()).matrix.map((row) => row.target);
}
/** Each matrix member's `artifact:`, as `release.yml` names the file it uploads. */
function* matrixArtifacts(): Operation {
- const workflow = yield* readTextFile(RELEASE_WORKFLOW);
- return [...workflow.matchAll(/^\s+artifact:\s*(\S+)/gm)].map((match) => match[1] ?? "");
+ return (yield* releaseWorkflow()).matrix.map((row) => row.artifact);
}
/** Executable lines only: a comment naming a command runs nothing. */
@@ -58,7 +56,13 @@ interface Job {
steps: Step[];
}
+interface ReleasePair {
+ target: string;
+ artifact: string;
+}
+
interface Workflow {
+ matrix: ReleasePair[];
permissions: Record;
jobs: Record;
}
@@ -129,7 +133,21 @@ function* releaseWorkflow(): Operation {
const source = yield* readTextFile(RELEASE_WORKFLOW);
const document = object(matter(`---\n${source}\n---`).data, "release.yml");
const jobs = object(document.jobs, "release.yml.jobs");
+ const build = object(jobs.build, "release.yml.jobs.build");
+ const strategy = object(build.strategy, "release.yml.jobs.build.strategy");
+ const matrix = object(strategy.matrix, "release.yml.jobs.build.strategy.matrix");
+ const label = "release.yml.jobs.build.strategy.matrix.include";
+ if (!Array.isArray(matrix.include)) {
+ throw new Error(`${label} is not an array`);
+ }
return {
+ matrix: matrix.include.map((entry, index) => {
+ const row = object(entry, `${label}[${index}]`);
+ return {
+ target: string(row.target, `${label}[${index}].target`),
+ artifact: string(row.artifact, `${label}[${index}].artifact`),
+ };
+ }),
permissions: stringRecord(document.permissions, "release.yml.permissions"),
jobs: Object.fromEntries(
Object.entries(jobs).map(([id, value]) => [id, job(value, `release.yml.jobs.${id}`)]),
@@ -184,6 +202,13 @@ describe("the shipped release-target table", () => {
]);
});
+ it("pairs every target with its own uploaded artifact", function* () {
+ const byTarget = (left: ReleasePair, right: ReleasePair) =>
+ left.target.localeCompare(right.target);
+ const published = PUBLISHED_TARGETS.map(({ target, artifact }) => ({ target, artifact }));
+ expect((yield* releaseWorkflow()).matrix.sort(byTarget)).toEqual(published.sort(byTarget));
+ });
+
it("names exactly the artifacts release.yml uploads, Windows suffix included", function* () {
const published = PUBLISHED_TARGETS.map((row) => row.artifact).sort();
expect(published).toEqual([...(yield* matrixArtifacts())].sort());
diff --git a/scripts/tests/runtime-tests.test.ts b/scripts/tests/runtime-tests.test.ts
index 7bbe8be75..f5a04f5ac 100644
--- a/scripts/tests/runtime-tests.test.ts
+++ b/scripts/tests/runtime-tests.test.ts
@@ -137,13 +137,14 @@ describe("running a shard", () => {
const runs = recorder();
const failing: RunLaunch = function* (launch) {
yield* runs.run(launch);
- return 3;
+ return runs.files.length + 2;
};
const outcome = yield* TestRuns.with(failing, () =>
runShardFiles("deno", [ONE, TWO, THREE], ROOT),
);
expect(runs.files).toEqual([ONE, TWO, THREE]);
+ expect(outcome.ran).toEqual([ONE, TWO, THREE]);
expect(outcome.failure).toEqual({ file: ONE, status: 3 });
});
diff --git a/scripts/tests/staged-write.test.ts b/scripts/tests/staged-write.test.ts
index 7c19e5b4b..8551a531e 100644
--- a/scripts/tests/staged-write.test.ts
+++ b/scripts/tests/staged-write.test.ts
@@ -55,11 +55,10 @@ describe("replaceThroughStaging", () => {
it("survives many concurrent replacements of the same file", function* () {
const file = yield* target("before");
- yield* all(
- Array.from({ length: 32 }, (_, index) => replaceThroughStaging(file, `after ${index}`)),
- );
+ const candidates = Array.from({ length: 32 }, (_, index) => `after ${index}`);
+ yield* all(candidates.map((contents) => replaceThroughStaging(file, contents)));
- expect(yield* readTextFile(file)).toContain("after ");
+ expect(candidates).toContain(yield* readTextFile(file));
expect(yield* directoryOf(file)).toEqual(["package.json"]);
});
diff --git a/scripts/tests/test-weights.test.ts b/scripts/tests/test-weights.test.ts
index e69282b64..a10763911 100644
--- a/scripts/tests/test-weights.test.ts
+++ b/scripts/tests/test-weights.test.ts
@@ -9,7 +9,7 @@ import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import { sleep } from "effection";
import type { Operation } from "effection";
-import { ensureDir, exists, readdir, writeTextFile } from "@effectionx/fs";
+import { ensureDir, exists, readdir, readTextFile, writeTextFile } from "@effectionx/fs";
import { useTempDirectory } from "@executablemd/test-support/temp";
import { pathToFileURL } from "node:url";
@@ -283,7 +283,7 @@ describe("measuring the corpus", () => {
expect(runs.launches.length).toEqual(2);
});
- it("replaces the file only after every measurement passed", function* () {
+ it("publishes a new file after every measurement passed", function* () {
const root = yield* workspace();
const target = weightsFile(root);
@@ -294,4 +294,25 @@ describe("measuring the corpus", () => {
expect(yield* exists(target)).toBe(true);
expect(yield* stagedNames(root)).toEqual([]);
});
+
+ it("replaces the older file with the exact completed measurements", function* () {
+ const root = yield* workspace();
+ const target = weightsFile(root);
+ const older = formatTestWeights(VALID);
+ yield* writeTextFile(target, older);
+ const runs = recorder();
+ const weights = yield* TestRuns.with(
+ function* (launch) {
+ expect(yield* readTextFile(target)).toBe(older);
+ return yield* runs.run(launch);
+ },
+ () => measureTestWeights(root, SOURCE),
+ );
+
+ yield* writeTestWeights(target, weights);
+
+ expect(runs.launches).toHaveLength(6);
+ expect(yield* readTextFile(target)).toBe(formatTestWeights(weights));
+ expect(yield* stagedNames(root)).toEqual([]);
+ });
});
diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md
index ddc0aad29..35bbaa378 100644
--- a/specs/executable-mdx-spec.md
+++ b/specs/executable-mdx-spec.md
@@ -12290,7 +12290,7 @@ through the captured capability because there is no other way to reach it.
| FE16 | Repeated Evaluate occurrences cannot consume one another's records. |
| FE17 | Actual cancellation stops active work, waits for cleanup, and records no false success. |
| FE18 | Existing untagged #369 records remain readable; new records use the closed version-2 shape. |
-| FE19 | Workflow `source` and canonical `text` have identical behavior without a warning; the ordinary profile rejects `source`, and every profile rejects `program`. |
+| FE19 | Canonical `text` produces identical output across fresh workflow executions without warnings; `source` and `program` refuse before fragment effects under every profile. |
| FE20 | Exact trusted Fetch limits are retained and narrowed; missing or changed limits refuse before requests. |
| FE21 | Ordinary and workflow hosts exercise the same fragment evaluator rather than separate semantic implementations. |
| FE22 | `xmd plan` output remains ordinary text usable at the command boundary and acquires no special complete-root type. |
@@ -13804,9 +13804,9 @@ ordering and teardown are proved with latches rather than elapsed time.
| SR5 | Demand runs the body | A read expands the region's authored body, including a nested component; a region created, expanded and subscribed to but never read runs nothing at all |
| SR6 | Region output is not document output | Chunks carry the region's rendered text while the construct renders nothing, and no chunk reaches `DocumentOutput` middleware |
| SR7 | One demand, one chunk | Subscribing runs nothing; the first read delivers the first chunk while the second element's work has not begun; the next element runs only once a later read asks for it, so a delivered chunk buys no work beyond itself |
-| SR8 | The handler's scope owns the producer | A handler returning while a producer is blocked on demand halts and joins it before the occurrence settles, so the unreached segment never runs |
+| SR8 | The handler's scope owns the producer | A handler returning while a producer is blocked on demand starts its cleanup; the occurrence stays unsettled behind a controlled cleanup gate and settles only after cleanup finishes, while the unreached segment never runs |
| SR9 | Prefix then failure | A body failing after output delivers its prefix first — including output one authored segment appended before failing partway through — then the next read raises the original failure object rather than a rebuilt one |
-| SR10 | Fresh each time | Repeated `expand()` calls are independent expansions with their own producers, and nothing refuses a second one |
+| SR10 | Fresh each time | Repeated `expand()` calls and two subscriptions to one returned stream each run independent producers and return their own output |
| SR11 | Nothing else moved | Expansion without an execution environment resolves the name as an ordinary component; with declarations present, the engine's own constructs and their refusals are unchanged and no handler is reached |
### Tier ED — One declaration catalog, holding both arms (§5.3)