Skip to content

Commit b5cc856

Browse files
authored
Enable existing assertions in ordinary REPL entries (#1067)
* ✨ Enable existing assertions in ordinary REPL entries * 📝 Correct ordinary REPL assertion guidance
1 parent 2e67bab commit b5cc856

6 files changed

Lines changed: 293 additions & 42 deletions

File tree

‎packages/cli/src/documents/sidekick/entries.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ Sidekick responses that write an entry, and the REPL entry program they write.
1515
`<REPL.Syntax names={["Elicit"]} />`. Availability means the component resolves
1616
in an ordinary entry; each effect still requires permission and can fail.
1717
Fresh asks read current session components and this command's selected Plugins.
18-
- Built-in assertions absent from the ordinary entry profile are Markdown-test
19-
components. Named lookup explains their test context and `REPL.Test` invocation.
20-
Save the test document and request it with `REPL.Test`; submitting assertions as
21-
an ordinary entry does not activate testing.
18+
- Use existing assertions such as `<Assert>`, `<AssertEquals>` and `<AssertThrows>`
19+
directly in ordinary entries. A failing assertion fails that entry and stops
20+
later ordinary effects. Assertions do not activate testing.
21+
- Use `Test`, `Testing` and the nested test harness in Markdown test documents
22+
requested through `<REPL.Test />`. A bare `<Test>` in an ordinary entry refuses
23+
without running its body or activating testing.
2224
- Quoted props are strings: `as="code"`. Braced props supply data:
2325
`limit={16384}`, `entry={-2}`, `names={["Elicit"]}`.
2426
- In a response, braced props accept JSON literals, arrays, objects, object

‎packages/cli/src/repl-profile.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ import type { ReplEntryInstallation } from "./repl-entry-installation.ts";
3636
import { inspectSyntax, renderSyntaxMarkdown } from "@executablemd/core";
3737
import { Component, Syntax } from "@executablemd/core/api";
3838
import { useSyntax } from "@executablemd/core/host";
39-
import { useTestingComponents } from "@executablemd/testing";
39+
import {
40+
TESTING_REGISTRATIONS,
41+
testingDocumentation,
42+
useTestingComponents,
43+
} from "@executablemd/testing";
44+
import { registerComponents, contributeDocumentation } from "@executablemd/core";
4045

4146
const REPL_INCLUDES: readonly string[] = Object.freeze(["components", "."]);
4247

@@ -214,6 +219,8 @@ export function* assembleReplProfile(
214219
plugins: CommandPlugins,
215220
facts: ReplProfileFacts = {},
216221
): Operation<ReplProfileAssembly> {
222+
yield* registerComponents(TESTING_REGISTRATIONS.filter(({ name }) => name.startsWith("Assert")));
223+
yield* contributeDocumentation(testingDocumentation);
217224
const projectCwd = yield* cwd();
218225
const mounts = facts.mounts;
219226
const configuredIncludes = Object.freeze(
@@ -357,7 +364,7 @@ export function entryVocabulary(
357364
yield* useSyntax(() => inspectSyntax(inputs));
358365
return (
359366
renderSyntaxMarkdown(yield* Syntax.operations.symbols) +
360-
"\nMarkdown test components are documented by name with REPL.Syntax and run through REPL.Test.\n" +
367+
"\nAssertions are available in ordinary entries. Markdown test helpers are documented by name with REPL.Syntax and run through REPL.Test.\n" +
361368
ENTRY_AVAILABILITY
362369
);
363370
});

‎packages/cli/tests/repl-sidekick-journey.test.ts‎

Lines changed: 220 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { SidekickOwnerError } from "../src/repl/sidekick/owner.ts";
2020
import { readFile, writeFile, readdir, stat } from "node:fs/promises";
2121
import { join } from "node:path";
2222
import { scoped } from "effection";
23-
import { ensureDir, writeTextFile, rm, exists } from "@effectionx/fs";
23+
import { ensureDir, writeTextFile, readTextFile, rm, exists } from "@effectionx/fs";
2424
import { API, cwd, useHostFiles, Permissions } from "@executablemd/runtime";
2525
import { useTemporary } from "./fixtures/repl/issue-883/sidekick-harness.ts";
2626
import { readGuideChapter } from "../src/repl/sidekick/guide.ts";
@@ -4035,7 +4035,198 @@ describe("REPL syntax matches ordinary entry execution (#1053)", () => {
40354035
).toContain(name);
40364036
}
40374037

4038+
it("ordinary assertions execute the advertised source (#1066)", function* () {
4039+
const directory = yield* useTemporary("xmd-1066-success-");
4040+
yield* API.Env.around({
4041+
*cwd() {
4042+
return directory;
4043+
},
4044+
});
4045+
const held = yield* useHarness();
4046+
const outcomes = yield* exchange(
4047+
held,
4048+
submission(`<Let as="actual" value={2 + 2} />
4049+
<AssertEquals actual={actual} expected={4} />
4050+
<Output>Check passed.</Output>`),
4051+
);
4052+
const outcome = outcomes.at(-1);
4053+
if (outcome?.outcome.status === "err") {
4054+
throw new Error(outcome.outcome.error.message);
4055+
}
4056+
expect(outcome?.outcome.status).toBe("ok");
4057+
expect(held.session.model.entries.at(-1)?.terminal).toMatchObject({
4058+
status: "ok",
4059+
output: "Check passed.",
4060+
});
4061+
});
4062+
4063+
it("ordinary assertion failure joins cleanup before the next entry (#1066)", function* () {
4064+
const directory = yield* useTemporary("xmd-1066-failure-");
4065+
yield* API.Env.around({
4066+
*cwd() {
4067+
return directory;
4068+
},
4069+
});
4070+
yield* useHostFiles();
4071+
let writes = 0;
4072+
yield* API.Files.around({
4073+
*writeTextFile(args, next) {
4074+
writes += 1;
4075+
return yield* next(...args);
4076+
},
4077+
});
4078+
const cleaning = withResolvers<void>();
4079+
const release = withResolvers<void>();
4080+
let installations = 0;
4081+
let cleanups = 0;
4082+
const held = yield* useHarness(undefined, {
4083+
entryInstallation: {
4084+
*install() {
4085+
installations += 1;
4086+
if (installations === 1) {
4087+
yield* ensure(function* () {
4088+
cleaning.resolve();
4089+
yield* release.operation;
4090+
cleanups += 1;
4091+
});
4092+
}
4093+
},
4094+
},
4095+
});
4096+
yield* ensure(() => release.resolve());
4097+
const submitted = yield* held.session.submit(
4098+
'<AssertEquals actual="actual-1066" expected="expected-1066" />\n<File path="sentinel.txt">unwritten</File>',
4099+
);
4100+
if (!submitted.ok) {
4101+
throw submitted.error;
4102+
}
4103+
let joined = false;
4104+
const joining = yield* spawn(function* () {
4105+
const result = yield* submitted.value.join();
4106+
joined = true;
4107+
return result;
4108+
});
4109+
yield* cleaning.operation;
4110+
expect(held.session.admits().ok).toBe(false);
4111+
const successor = yield* spawn(() => held.session.submit("Too early."));
4112+
expect((yield* successor).ok).toBe(false);
4113+
expect(joined).toBe(false);
4114+
expect(cleanups).toBe(0);
4115+
expect(writes).toBe(0);
4116+
expect(yield* exists(join(directory, "sentinel.txt"))).toBe(false);
4117+
release.resolve();
4118+
const failed = yield* joining;
4119+
expect(failed.ok).toBe(false);
4120+
expect(cleanups).toBe(1);
4121+
const terminal = held.session.model.entries[0]?.terminal;
4122+
expect(terminal?.status).toBe("err");
4123+
expect(terminal?.message).toContain("actual-1066");
4124+
expect(terminal?.message).toContain("expected-1066");
4125+
const next = yield* held.session.submit(
4126+
"<Assert expr={true} /><Output>Next entry passed.</Output>",
4127+
);
4128+
if (!next.ok) {
4129+
throw next.error;
4130+
}
4131+
expect(next.value.entry).not.toBe(submitted.value.entry);
4132+
expect((yield* next.value.join()).ok).toBe(true);
4133+
expect(held.session.model.entries[1]?.terminal).toMatchObject({
4134+
status: "ok",
4135+
output: "Next entry passed.",
4136+
});
4137+
expect(yield* submitted.value.join()).toEqual(failed);
4138+
expect(held.session.model.entries[0]?.terminal).toEqual(terminal);
4139+
expect(cleanups).toBe(1);
4140+
expect(installations).toBe(2);
4141+
expect(held.fake.prompts).toHaveLength(0);
4142+
const journal = yield* readTextFile(held.execution.stream.path);
4143+
expect(journal).not.toContain("test_result");
4144+
expect(journal).not.toContain("testing_boundary");
4145+
});
4146+
4147+
it("ordinary assertions retain forms, validation and explicit test boundaries (#1066)", function* () {
4148+
const directory = yield* useTemporary("xmd-1066-forms-");
4149+
yield* API.Env.around({
4150+
*cwd() {
4151+
return directory;
4152+
},
4153+
});
4154+
yield* useHostFiles();
4155+
let writes = 0;
4156+
yield* API.Files.around({
4157+
*writeTextFile(args, next) {
4158+
writes += 1;
4159+
return yield* next(...args);
4160+
},
4161+
});
4162+
const held = yield* useHarness();
4163+
const forms = yield* held.session
4164+
.submit(`<AssertThrows message={/cannot read/} as="caught"><File path="missing-1066.txt" /></AssertThrows>
4165+
<AssertEquals actual={caught.source} expected="File" />
4166+
<AssertStringIncludes actual={caught.message} expected="cannot read" />
4167+
<AssertEquals actual="paired">paired</AssertEquals>
4168+
<Output>Forms passed.</Output>`);
4169+
if (!forms.ok) {
4170+
throw forms.error;
4171+
}
4172+
const formResult = yield* forms.value.join();
4173+
if (!formResult.ok) {
4174+
throw formResult.error;
4175+
}
4176+
expect(held.session.model.entries.at(-1)?.terminal?.output).toBe("Forms passed.");
4177+
const invalid = yield* held.session.submit(
4178+
'<Assert expr={true} bogus="invalid" /><File path="invalid.txt">unwritten</File>',
4179+
);
4180+
expect(invalid.ok).toBe(false);
4181+
if (!invalid.ok) {
4182+
expect(invalid.error.message).toContain("bogus");
4183+
}
4184+
expect(held.session.model.entries).toHaveLength(1);
4185+
const missing = yield* held.session.submit(
4186+
'<AssertThrows><File path="missing-1066.txt" /></AssertThrows>',
4187+
);
4188+
if (!missing.ok) {
4189+
throw missing.error;
4190+
}
4191+
const missingResult = yield* missing.value.join();
4192+
expect(missingResult.ok).toBe(false);
4193+
if (!missingResult.ok) {
4194+
expect(missingResult.error.message).toContain('requires a "message"');
4195+
}
4196+
const bare = yield* held.session.submit(
4197+
'<Test name="inactive"><File path="test-body.txt">unwritten</File></Test>',
4198+
);
4199+
if (!bare.ok) {
4200+
throw bare.error;
4201+
}
4202+
expect((yield* bare.value.join()).ok).toBe(false);
4203+
expect(held.session.model.entries.at(-1)?.terminal?.message).toContain(
4204+
"requires a testing package",
4205+
);
4206+
const agent = yield* held.session.submit(
4207+
'<TestAgent><File path="agent-body.txt">unwritten</File></TestAgent>',
4208+
);
4209+
expect(agent.ok).toBe(false);
4210+
if (!agent.ok) {
4211+
expect(agent.error.message).toContain("Cannot resolve component: TestAgent");
4212+
}
4213+
expect(writes).toBe(0);
4214+
for (const path of ["invalid.txt", "test-body.txt", "agent-body.txt"]) {
4215+
expect(yield* exists(join(directory, path))).toBe(false);
4216+
}
4217+
expect(held.fake.prompts).toHaveLength(0);
4218+
const journal = yield* readTextFile(held.execution.stream.path);
4219+
expect(journal).not.toContain("test_result");
4220+
expect(journal).not.toContain("testing_boundary");
4221+
});
4222+
40384223
it("A1-A4/A7: discovery, admission, permissions and Markdown tests agree", function* () {
4224+
const directory = yield* useTemporary("xmd-1066-discovery-");
4225+
yield* API.Env.around({
4226+
*cwd() {
4227+
return directory;
4228+
},
4229+
});
40394230
yield* useHostFiles();
40404231
let writesRequested = 0;
40414232
yield* Permissions.around({
@@ -4054,41 +4245,37 @@ describe("REPL syntax matches ordinary entry execution (#1053)", () => {
40544245
<REPL.Syntax names={["Json"]} />
40554246
<REPL.Syntax names={["Assert"]} />
40564247
<REPL.Syntax names={["AssertEquals"]} />
4057-
<REPL.Syntax names={["File"]} />`,
4248+
<REPL.Syntax names={["File"]} />
4249+
<REPL.Syntax names={["Testing"]} />`,
40584250
);
40594251
const listing = documented(discovery);
40604252
expect(listing).toContain("### `<Json>`");
40614253
expect(listing).toContain("### `<File>`");
4062-
for (const name of ["Assert", "AssertEquals", "Verbose", "WebForm"]) {
4254+
for (const name of ["Assert", "AssertEquals"]) {
4255+
expect(listing).toContain(`### \`<${name}>\``);
4256+
}
4257+
for (const name of ["Testing", "Verbose", "WebForm"]) {
40634258
expect(listing).not.toContain(`### \`<${name}>\``);
40644259
}
40654260
expect(documented(discovery, 1)).toContain("**Available in this evaluation:** yes");
40664261
expect(documented(discovery, 4)).toContain("**Available in this evaluation:** yes");
40674262
for (const index of [2, 3]) {
4068-
const text = documented(discovery, index);
4069-
expect(text).toContain("**Available in this evaluation:** no");
4070-
expect(text).toContain("ordinary entry");
4071-
expect(text).toContain("Markdown test");
4072-
expect(text).toContain("<Assert expr={true} />");
4073-
expect(text).toContain('<AssertEquals actual="yes" expected="yes" />');
4074-
expect(text).toContain('<REPL.Test path="components/assertions.test.md" />');
4263+
expect(documented(discovery, index)).toContain("**Available in this evaluation:** yes");
40754264
}
4265+
const reference = documented(discovery, 5);
4266+
expect(reference).toContain("**Available in this evaluation:** no");
4267+
expect(reference).toContain("Markdown test");
4268+
expect(reference).toContain('<REPL.Test path="components/assertions.test.md" />');
40764269
expect(held.session.model.entries).toHaveLength(0);
40774270
expect(yield* exists(join(held.workspace, "sentinel.txt"))).toBe(false);
4078-
for (const source of [
4079-
"<Assert expr={true} />",
4080-
'<AssertEquals actual="yes" expected="yes" />',
4081-
"<NotAComponent1053 />",
4082-
]) {
4083-
const outcomes = yield* exchange(
4084-
held,
4085-
submission(`${source}<File path="sentinel.txt">unwritten</File>`),
4086-
);
4087-
refused(outcomes, "Cannot resolve component");
4088-
expect(held.session.model.entries).toHaveLength(0);
4089-
expect(yield* exists(join(held.workspace, "sentinel.txt"))).toBe(false);
4090-
expect(writesRequested).toBe(0);
4091-
}
4271+
const outcomes = yield* exchange(
4272+
held,
4273+
submission('<NotAComponent1053 /><File path="sentinel.txt">unwritten</File>'),
4274+
);
4275+
refused(outcomes, "Cannot resolve component");
4276+
expect(held.session.model.entries).toHaveLength(0);
4277+
expect(yield* exists(join(held.workspace, "sentinel.txt"))).toBe(false);
4278+
expect(writesRequested).toBe(0);
40924279
const unknown = yield* exchange(
40934280
held,
40944281
`<REPL.Syntax names={["NotAComponent1053"]} />
@@ -4111,7 +4298,7 @@ describe("REPL syntax matches ordinary entry execution (#1053)", () => {
41114298
expect(yield* exists(join(held.workspace, "refused.txt"))).toBe(false);
41124299
expect(writesRequested).toBeGreaterThan(0);
41134300
yield* ensureDir(join(held.workspace, "components"));
4114-
const examples = documented(discovery, 2).split("```xmd\n");
4301+
const examples = reference.split("```xmd\n");
41154302
const testSource = examples[1]?.split("\n```")[0];
41164303
const testRequest = examples[2]?.split("\n```")[0];
41174304
if (testSource === undefined || testRequest === undefined) {
@@ -4129,7 +4316,7 @@ describe("REPL syntax matches ordinary entry execution (#1053)", () => {
41294316
it("A5: fresh asks follow session component creation, edits and removal", function* () {
41304317
const held = yield* useHarness();
41314318
const before = yield* exchange(held, '<REPL.Syntax names={["Assert"]} />');
4132-
expect(documented(before)).toContain("**Available in this evaluation:** no");
4319+
expect(documented(before)).toContain("**Available in this evaluation:** yes");
41334320
yield* ensureDir(join(held.workspace, "components"));
41344321
const path = join(held.workspace, "components/Assert.md");
41354322
for (const version of ["first", "second"]) {
@@ -4161,14 +4348,13 @@ Session body ${version}.`,
41614348
`<REPL.Syntax />
41624349
<REPL.Syntax names={["Assert"]} />`,
41634350
);
4164-
expect(documented(removed)).not.toContain("### `<Assert>`");
4165-
expect(documented(removed, 1)).toContain("**Available in this evaluation:** no");
4166-
refused(
4167-
yield* exchange(held, submission("<Assert expr={true} />")),
4168-
"Cannot resolve component: Assert",
4169-
);
4170-
expect(held.session.model.entries).toHaveLength(2);
4171-
expect(documented(before)).toContain("**Available in this evaluation:** no");
4351+
expect(documented(removed)).toContain("### `<Assert>`");
4352+
expect(documented(removed, 1)).toContain("**Available in this evaluation:** yes");
4353+
expect(documented(removed, 1)).not.toContain("Session assertion");
4354+
yield* exchange(held, submission("<Assert expr={true} />"));
4355+
expect(held.session.model.entries.at(-1)?.terminal?.status).toBe("ok");
4356+
expect(held.session.model.entries).toHaveLength(3);
4357+
expect(documented(before)).toContain("**Available in this evaluation:** yes");
41724358
const retained = yield* records(held.execution);
41734359
expect(retained).toContainEqual(before[0]);
41744360
});

‎packages/testing/tests/cli.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,36 @@ describe("xmd CLI", () => {
5959
expect(result.stderr).toContain("no tests were discovered");
6060
});
6161

62+
it("run continues after the advertised ordinary assertion", function* () {
63+
const result = yield* runCli(
64+
[
65+
"run",
66+
"--eval",
67+
`<Let as="actual" value={2 + 2} />
68+
<AssertEquals actual={actual} expected={4} />
69+
<Output>Check passed.</Output>`,
70+
],
71+
RUN,
72+
).join();
73+
expect(result.code).toBe(0);
74+
expect(result.stdout.trim()).toBe("Check passed.");
75+
});
76+
77+
it("run fails an assertion with operand diagnostics before later output", function* () {
78+
const result = yield* runCli(
79+
[
80+
"run",
81+
"--eval",
82+
'<AssertEquals actual="actual-1066" expected="expected-1066" /><Output>LATER-EFFECT-1066</Output>',
83+
],
84+
RUN,
85+
).join();
86+
expect(result.code).toBe(1);
87+
expect(result.stdout + result.stderr).toContain("actual-1066");
88+
expect(result.stdout + result.stderr).toContain("expected-1066");
89+
expect(result.stdout).not.toContain("LATER-EFFECT-1066");
90+
});
91+
6292
it("run skips tests entirely and exits 0", function* () {
6393
const result = yield* runCli(["run", "packages/testing/tests/fixtures/failing.md"], RUN).join();
6494
expect(result.code).toBe(0);

0 commit comments

Comments
 (0)