Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 44 additions & 29 deletions packages/cli/tests/evaluate-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
`<File path="notes.md">${NOTE}</File>\n\n` +
`<Evaluate text={'<File path="notes.md" />\\n'} as="answer" />\n\n` +
`<Json value={answer} />\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(
Expand All @@ -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,
`<Evaluate program={'<File path="notes.md" />\\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,
`<File path="notes.md">${NOTE}</File>\n\n` +
`<Evaluate ${prop}={'<File path="notes.md" />\\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();
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/tests/repl-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
103 changes: 91 additions & 12 deletions packages/core/tests/expand.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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<void>();
const cleanupAllowed = withResolvers<void>();
const environment = yield* installedEnvironment(
[structuralDeclaration(), regionDeclaration()],
function* (request) {
Expand All @@ -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",
Expand All @@ -1551,17 +1556,48 @@ describe("Tier SR — expanding installed structural syntax", () => {
},
},
});
yield* expandInstalled(
'<Deck>\n <Panel title="One"><Step n={1} />\n\n<Step n={2} /></Panel>\n</Deck>\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(
'<Deck>\n <Panel title="One">\n```text exec\nfirst\n```\n\n' +
"<Step n={2} /></Panel>\n</Deck>\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* () {
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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(
'<Deck>\n <Panel title="One"><Count /></Panel>\n</Deck>\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* () {
Expand Down
2 changes: 1 addition & 1 deletion scripts/runtime-test-exclusions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
{
Expand Down
33 changes: 29 additions & 4 deletions scripts/tests/release-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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<string[]> {
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. */
Expand All @@ -58,7 +56,13 @@ interface Job {
steps: Step[];
}

interface ReleasePair {
target: string;
artifact: string;
}

interface Workflow {
matrix: ReleasePair[];
permissions: Record<string, string>;
jobs: Record<string, Job>;
}
Expand Down Expand Up @@ -129,7 +133,21 @@ function* releaseWorkflow(): Operation<Workflow> {
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}`)]),
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 2 additions & 1 deletion scripts/tests/runtime-tests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

Expand Down
7 changes: 3 additions & 4 deletions scripts/tests/staged-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});

Expand Down
Loading
Loading