@shivaedev/test-story 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +284 -1
- package/dist/genreTags.d.ts +6 -0
- package/dist/genreTags.d.ts.map +1 -0
- package/dist/genreTags.js +5 -0
- package/dist/genreTags.js.map +1 -0
- package/dist/internal/callSite.d.ts +3 -0
- package/dist/internal/callSite.d.ts.map +1 -0
- package/dist/internal/callSite.js +17 -0
- package/dist/internal/callSite.js.map +1 -0
- package/dist/internal/engineSnapshot.d.ts +4 -0
- package/dist/internal/engineSnapshot.d.ts.map +1 -0
- package/dist/internal/engineSnapshot.js +36 -0
- package/dist/internal/engineSnapshot.js.map +1 -0
- package/dist/internal/errors.d.ts +5 -0
- package/dist/internal/errors.d.ts.map +1 -0
- package/dist/internal/errors.js +15 -0
- package/dist/internal/errors.js.map +1 -0
- package/dist/internal/failureReport.d.ts +8 -0
- package/dist/internal/failureReport.d.ts.map +1 -0
- package/dist/internal/failureReport.js +41 -0
- package/dist/internal/failureReport.js.map +1 -0
- package/dist/internal/genre.d.ts +7 -0
- package/dist/internal/genre.d.ts.map +1 -0
- package/dist/internal/genre.js +8 -0
- package/dist/internal/genre.js.map +1 -0
- package/dist/internal/hooked.d.ts +7 -0
- package/dist/internal/hooked.d.ts.map +1 -0
- package/dist/internal/hooked.js +11 -0
- package/dist/internal/hooked.js.map +1 -0
- package/dist/internal/narration.d.ts +25 -0
- package/dist/internal/narration.d.ts.map +1 -0
- package/dist/internal/narration.js +34 -0
- package/dist/internal/narration.js.map +1 -0
- package/dist/internal/render.d.ts +8 -0
- package/dist/internal/render.d.ts.map +1 -0
- package/dist/internal/render.js +56 -0
- package/dist/internal/render.js.map +1 -0
- package/dist/internal/runUntil.d.ts +17 -0
- package/dist/internal/runUntil.d.ts.map +1 -0
- package/dist/internal/runUntil.js +24 -0
- package/dist/internal/runUntil.js.map +1 -0
- package/dist/internal/seed.d.ts +29 -0
- package/dist/internal/seed.d.ts.map +1 -0
- package/dist/internal/seed.js +27 -0
- package/dist/internal/seed.js.map +1 -0
- package/dist/internal/start.d.ts +7 -0
- package/dist/internal/start.d.ts.map +1 -0
- package/dist/internal/start.js +23 -0
- package/dist/internal/start.js.map +1 -0
- package/dist/internal/storyIt.d.ts +16 -0
- package/dist/internal/storyIt.d.ts.map +1 -0
- package/dist/internal/storyIt.js +51 -0
- package/dist/internal/storyIt.js.map +1 -0
- package/dist/internal/traits.d.ts +12 -0
- package/dist/internal/traits.d.ts.map +1 -0
- package/dist/internal/traits.js +8 -0
- package/dist/internal/traits.js.map +1 -0
- package/dist/internal/wrap.d.ts +8 -0
- package/dist/internal/wrap.d.ts.map +1 -0
- package/dist/internal/wrap.js +49 -0
- package/dist/internal/wrap.js.map +1 -0
- package/dist/storyKit.d.ts +67 -0
- package/dist/storyKit.d.ts.map +1 -0
- package/dist/storyKit.js +11 -0
- package/dist/storyKit.js.map +1 -0
- package/docs/north-star.md +35 -0
- package/docs/roadmap.md +23 -0
- package/package.json +67 -3
- package/src/genreTags.ts +10 -0
- package/src/internal/callSite.ts +19 -0
- package/src/internal/engineSnapshot.ts +48 -0
- package/src/internal/errors.ts +39 -0
- package/src/internal/failureReport.ts +55 -0
- package/src/internal/genre.ts +14 -0
- package/src/internal/hooked.ts +22 -0
- package/src/internal/narration.ts +57 -0
- package/src/internal/render.ts +69 -0
- package/src/internal/runUntil.ts +43 -0
- package/src/internal/seed.ts +56 -0
- package/src/internal/start.ts +24 -0
- package/src/internal/storyIt.ts +68 -0
- package/src/internal/traits.ts +20 -0
- package/src/internal/wrap.ts +50 -0
- package/src/storyKit.ts +130 -0
- package/login.log +0 -26
- package/publish.log +0 -0
- package/run.out +0 -26
- package/run.sh +0 -10
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# North star
|
|
2
|
+
|
|
3
|
+
## The problem
|
|
4
|
+
|
|
5
|
+
Agents now write most of the code, tests included, and a test file an agent writes tends to be hundreds of lines long. Each test rebuilds its setup by hand, a little differently from the last, and where rebuilding a complex setup is too much work, it mocks the part that was hard to build. Nobody reads these files. A human could ask an agent what a suite actually tests, but nobody asks consistently enough for that to be a safety net, so we lose sight of what we test exactly when more code is written than ever.
|
|
6
|
+
|
|
7
|
+
## The ideal
|
|
8
|
+
|
|
9
|
+
A test reads like a short English story: what exists, what someone does, and what is true afterwards. Its setup is a list of traits in the domain's words, such as `ovenIsLit()` and `hasDough(3)`, its actions go through the application's real verbs, and the real engine runs between them. Everything that is not specific to this one test lives elsewhere, written once:
|
|
10
|
+
|
|
11
|
+
- once per engine, a story kit that knows how to create the engine, seed it stage by stage, step it and show its state;
|
|
12
|
+
- once per feature, the traits and verbs that name that feature in domain words;
|
|
13
|
+
- in each test, only the story.
|
|
14
|
+
|
|
15
|
+
This changes the cost and benefit of a good test for both readers. For an agent, a story test is the cheaper one to write and to read on every later visit, because it is a handful of lines that reuse what already exists. Because every story runs the whole engine, each test also checks, almost by accident, the parts its author never thought about and would have mocked. For a human, a spec that reads as English leaves no excuse not to read it. With so little noise around the signal, an agent that writes a sloppy or pointless test produces something a reviewer sees at a glance.
|
|
16
|
+
|
|
17
|
+
## What good looks like
|
|
18
|
+
|
|
19
|
+
- A spec is a few lines of domain words. If a spec needs a helper, a loop or hand-built state, a trait or verb is missing from the kit.
|
|
20
|
+
- An impossible setup refuses loudly instead of seeding a state the application could never reach.
|
|
21
|
+
- A failure is a console version of stopping in a debugger: the whole story, where it stopped and the spec line there, and the engine's state at that moment, written so an agent with no knowledge of this package can fix the test, and complete enough that a CI log alone is enough to reproduce it.
|
|
22
|
+
- The kit author writes hooks and the package infers every type from them. There is one kit, built on Effect: a hook is a plain function, or a generator function when it needs a service or fails with a typed error, so no definition writes `Effect.gen` and a plain engine needs no Effect at all.
|
|
23
|
+
- A spec writes only its story. The kit hands it an `it` that already knows the engine's services, so a spec never builds a test harness, and names every test after its genre, so a report shows which engine it ran and a run can pick one engine's stories.
|
|
24
|
+
|
|
25
|
+
## Trade-offs
|
|
26
|
+
|
|
27
|
+
- **Real engine over speed.** A story is slower than a unit test with a mock. We accept that, and keep engines fast enough to step in a test instead of faking them.
|
|
28
|
+
- **Hooks over freedom.** The kit fixes the shape of an engine: create, stages, after-stage hooks, a step, a state to inspect, verbs. An engine that does not fit should change its kit's hooks, not bypass the kit.
|
|
29
|
+
- **Cheap locations over complete ones.** The failure shows one spec line, where the story stopped, found by matching the spec's path in the failure's stack or in the call site of the story's last line. A failure raised deep inside an Effect verb may have none; finding one would take machinery we do not want.
|
|
30
|
+
|
|
31
|
+
## What it will not do
|
|
32
|
+
|
|
33
|
+
- Run without Vitest, or print a failure anywhere but the failing test's own error.
|
|
34
|
+
- Own any application's traits, verbs or steps; they belong to the application's `test-support/`.
|
|
35
|
+
- Mock the application's own code, or help a test reach into an engine's internals. A fake belongs only at a true external boundary, and the kit's `create` is where it is wired in.
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
This file owns the story kit's implementation, next work and open questions. The
|
|
4
|
+
[framework roadmap](https://github.com/ShivaeDev/platform/blob/main/docs/framework/roadmap.md)
|
|
5
|
+
owns composed application features and host-level validation. Those roadmaps link
|
|
6
|
+
to each other rather than repeating a completion checklist.
|
|
7
|
+
|
|
8
|
+
## Built
|
|
9
|
+
|
|
10
|
+
- [x] `storyKit(definition)`: one Effect kit per engine, with every type and the services its layer provides inferred from its hooks. Plain engines plug in with plain functions.
|
|
11
|
+
- [x] Traits seeded stage by stage, with a hook after each stage, and refusal of an impossible setup.
|
|
12
|
+
- [x] The kit's own `it`, built on `@shivaedev/effect-test`, which hands the body the verbs and the story and names every test after the kit's genre. Vitest tags per genre are opt-in through `genreTags(...kitNames)` in the Vitest config.
|
|
13
|
+
- [x] `story.runUntil(condition)`: steps the engine until the condition holds, stops on what `run.failed` reports, and stops after a step budget with a diagnosis, keeping every hook's typed failure in its error channel.
|
|
14
|
+
- [x] A failed test prints a short guide, its story, where it stopped with the spec line there, and the engine's state, inline or in a file under `node_modules/.cache/test-story/`, broken at 120 columns.
|
|
15
|
+
|
|
16
|
+
## Next
|
|
17
|
+
|
|
18
|
+
- [ ] Move a first application's hand-written story DSL onto the kit and let what it needs decide the next hook.
|
|
19
|
+
|
|
20
|
+
## Open questions
|
|
21
|
+
|
|
22
|
+
- A long story prints in full. Should a story of thousands of lines print its traits and its last lines, and write the rest to a file the way a large engine state is written?
|
|
23
|
+
- `inspect` runs synchronously when the test has failed. An engine whose state lives behind an Effect service cannot be read then. Is that worth an Effect inspect, captured when the failure happens?
|
package/package.json
CHANGED
|
@@ -1,11 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/test-story",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Tests that read like stories over the real engine: a story kit per engine with its own it, traits per feature, and a failure that prints the story, where it stopped and the engine's state",
|
|
5
|
+
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/test-story#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/ShivaeDev/platform/issues"
|
|
8
|
+
},
|
|
6
9
|
"repository": {
|
|
7
10
|
"type": "git",
|
|
8
11
|
"url": "git+https://github.com/ShivaeDev/platform.git",
|
|
9
12
|
"directory": "packages/test-story"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"type": "module",
|
|
17
|
+
"imports": {
|
|
18
|
+
"#test/*": "./src/test-support/*",
|
|
19
|
+
"#*.ts": {
|
|
20
|
+
"source": "./src/*.ts",
|
|
21
|
+
"types": "./dist/*.d.ts",
|
|
22
|
+
"default": "./dist/*.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
"./*.ts": {
|
|
27
|
+
"types": "./dist/*.d.ts",
|
|
28
|
+
"source": "./src/*.ts",
|
|
29
|
+
"import": "./dist/*.js",
|
|
30
|
+
"default": "./dist/*.js"
|
|
31
|
+
},
|
|
32
|
+
"./internal/*": null,
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src",
|
|
38
|
+
"!src/**/*.test.ts",
|
|
39
|
+
"!src/**/*.spec.ts",
|
|
40
|
+
"!src/**/test-support",
|
|
41
|
+
"docs",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"README.md"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@shivaedev/effect-test": "0.3.2"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@effect/vitest": "4.0.0-rc.112",
|
|
50
|
+
"@shivaedev/quality": "0.19.0",
|
|
51
|
+
"@types/node": "24.19.1",
|
|
52
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
53
|
+
"effect": "4.0.0-rc.112",
|
|
54
|
+
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
55
|
+
"vitest": "4.1.11"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@effect/vitest": "4.0.0-rc.112",
|
|
59
|
+
"effect": "4.0.0-rc.112",
|
|
60
|
+
"vitest": "4.1.11"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=24"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public",
|
|
67
|
+
"provenance": true
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
|
|
71
|
+
"ready": "pnpm typecheck && pnpm test && pnpm build",
|
|
72
|
+
"test": "vitest run",
|
|
73
|
+
"typecheck": "tsc --noEmit"
|
|
10
74
|
}
|
|
11
75
|
}
|
package/src/genreTags.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { genreOf } from "#internal/genre.ts";
|
|
2
|
+
|
|
3
|
+
export interface GenreTag {
|
|
4
|
+
readonly description: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function genreTags(...kitNames: readonly string[]): GenreTag[] {
|
|
9
|
+
return kitNames.map((kitName) => genreOf(kitName).tag);
|
|
10
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
const FRAMES = 50;
|
|
5
|
+
|
|
6
|
+
// V8 formats a stack only when it is read, so a site costs little until a test fails.
|
|
7
|
+
export function callSite(): Error {
|
|
8
|
+
const limit = Error.stackTraceLimit;
|
|
9
|
+
Error.stackTraceLimit = FRAMES;
|
|
10
|
+
const site = new Error("call site");
|
|
11
|
+
Error.stackTraceLimit = limit;
|
|
12
|
+
return site;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function locate(stack: string | undefined, testFile: string): string | undefined {
|
|
16
|
+
const frame = stack?.split("\n").find((line) => line.includes(testFile));
|
|
17
|
+
const position = frame?.slice(frame.indexOf(testFile) + testFile.length).match(/^:(?<line>\d+):(?<column>\d+)/u)?.groups;
|
|
18
|
+
return position === undefined ? undefined : `${relative(process.cwd(), testFile)}:${position.line}:${position.column}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import type { FailedTest } from "#internal/failureReport.ts";
|
|
5
|
+
import type { Subject } from "#internal/narration.ts";
|
|
6
|
+
import { describeValue, toJson } from "#internal/render.ts";
|
|
7
|
+
import { WIDTH, wrap, wrapJson } from "#internal/wrap.ts";
|
|
8
|
+
|
|
9
|
+
const INLINE_CHARACTERS = 2000;
|
|
10
|
+
|
|
11
|
+
type Snapshot = { readonly value: unknown } | { readonly threw: unknown };
|
|
12
|
+
|
|
13
|
+
function snapshot<TEngine>(subject: Subject<TEngine>): Snapshot {
|
|
14
|
+
try {
|
|
15
|
+
return { value: subject.inspect === undefined ? subject.engine : subject.inspect(subject.engine) };
|
|
16
|
+
} catch (threw) {
|
|
17
|
+
return { threw };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function slug(text: string): string {
|
|
22
|
+
return text
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replaceAll(/[^a-z0-9.]+/gu, "-")
|
|
25
|
+
.slice(0, 80);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function engineSnapshot<TEngine>(subject: Subject<TEngine>, test: FailedTest): string[] {
|
|
29
|
+
const heading = `The ${subject.name} when the test failed`;
|
|
30
|
+
const taken = snapshot(subject);
|
|
31
|
+
if ("threw" in taken) {
|
|
32
|
+
return wrap(`${heading} is unknown: its inspect hook threw ${describeValue(taken.threw)}`);
|
|
33
|
+
}
|
|
34
|
+
const json = toJson(taken.value);
|
|
35
|
+
if (json.length <= INLINE_CHARACTERS) {
|
|
36
|
+
return heading.length + 2 + json.length > WIDTH ? [`${heading}:`, ...wrapJson(json)] : [`${heading}: ${json}`];
|
|
37
|
+
}
|
|
38
|
+
const file = join(
|
|
39
|
+
process.cwd(),
|
|
40
|
+
"node_modules",
|
|
41
|
+
".cache",
|
|
42
|
+
"test-story",
|
|
43
|
+
`${slug(basename(test.file))}--${slug(test.name)}--${slug(subject.name)}.json`,
|
|
44
|
+
);
|
|
45
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
46
|
+
writeFileSync(file, toJson(taken.value, 2));
|
|
47
|
+
return wrap(`${heading} is ${json.length} characters of JSON, too long to print here. Read it in ${relative(process.cwd(), file)}`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describeValue } from "#internal/render.ts";
|
|
2
|
+
import { explained } from "#internal/wrap.ts";
|
|
3
|
+
|
|
4
|
+
export function refusal(name: string, line: string, cause: unknown): Error {
|
|
5
|
+
return new Error(
|
|
6
|
+
explained(
|
|
7
|
+
`the trait "${line}" refused to set up the ${name}: ${describeValue(cause)}`,
|
|
8
|
+
`a trait throws when the ${name} it asks for cannot exist. Give the test traits that fit together, or fix the trait in the ${name} story kit if this ${name} should be possible.`,
|
|
9
|
+
),
|
|
10
|
+
{ cause },
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function broken(name: string, steps: number, reason: string): Error {
|
|
15
|
+
return new Error(
|
|
16
|
+
explained(
|
|
17
|
+
`the ${name} broke after ${steps} steps: ${reason}`,
|
|
18
|
+
`run.failed in the ${name} story kit reports a state the ${name} cannot recover from. The story printed with this failure shows the setup and steps that led here.`,
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function unfinished(name: string, steps: number, diagnosis: string | undefined): Error {
|
|
24
|
+
return new Error(
|
|
25
|
+
explained(
|
|
26
|
+
`the ${name} ran ${steps} steps and never reached what runUntil waits for${diagnosis === undefined ? "" : `: ${diagnosis}`}`,
|
|
27
|
+
`either the ${name} never gets there, so check the setup and the engine, or it needs more steps, so pass a larger maxSteps to runUntil.`,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cannotRun(name: string): Error {
|
|
33
|
+
return new Error(
|
|
34
|
+
explained(
|
|
35
|
+
`the ${name} story kit has no run hooks, so runUntil cannot step the ${name}`,
|
|
36
|
+
`give the ${name} story kit run: { maxSteps, step }, where step advances the ${name} by one step.`,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { locate } from "#internal/callSite.ts";
|
|
4
|
+
import { engineSnapshot } from "#internal/engineSnapshot.ts";
|
|
5
|
+
import type { Narration, Subject } from "#internal/narration.ts";
|
|
6
|
+
import { describeValue } from "#internal/render.ts";
|
|
7
|
+
import { WIDTH, wrap } from "#internal/wrap.ts";
|
|
8
|
+
|
|
9
|
+
export interface FailedTest {
|
|
10
|
+
readonly file: string;
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly stack: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function failureReport<TEngine>(narration: Narration, subject: Subject<TEngine>, test: FailedTest): string {
|
|
16
|
+
try {
|
|
17
|
+
return [...guide(subject.name), "", ...storyRows(narration, test), "", ...engineSnapshot(subject, test), "", rerun(test)].join("\n");
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return `test-story could not print the story of this ${subject.name}: ${describeValue(error)}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function guide(name: string): string[] {
|
|
24
|
+
return [
|
|
25
|
+
"╭─ test-story: how to read the story below",
|
|
26
|
+
...wrap(
|
|
27
|
+
`"given" lines are the test's traits, the other lines were told by verbs and engine steps as they ran, and ✗ marks where the test stopped. The traits, verbs and steps live in the ${name} story kit that this test imports.`,
|
|
28
|
+
{ first: "│ ", rest: "│ " },
|
|
29
|
+
),
|
|
30
|
+
"╰─",
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function storyRows(narration: Narration, test: FailedTest): string[] {
|
|
35
|
+
const rows = narration.lines.map(
|
|
36
|
+
(line, index) => `${narration.refused?.index === index ? "✗" : " "} ${index < narration.given ? "given" : " "} ${line}`,
|
|
37
|
+
);
|
|
38
|
+
if (narration.refused !== undefined) {
|
|
39
|
+
const where = locate(narration.sites[narration.refused.index]?.stack, test.file);
|
|
40
|
+
rows.splice(narration.refused.index + 1, 0, ...(where === undefined ? [] : [` refused at ${where}`]));
|
|
41
|
+
return rows;
|
|
42
|
+
}
|
|
43
|
+
const where = locate(test.stack, test.file) ?? locate(narration.at?.stack, test.file);
|
|
44
|
+
return [
|
|
45
|
+
...rows,
|
|
46
|
+
`✗ the test failed ${rows.length === 0 ? "before the story told a line" : "after the line above"}`,
|
|
47
|
+
...(where === undefined ? [] : [` at ${where}`]),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rerun(test: FailedTest): string {
|
|
52
|
+
const run = `Rerun: vitest run ${relative(process.cwd(), test.file)}`;
|
|
53
|
+
const filter = `-t "${test.name.replaceAll(/[$()*+.?[\\\]^{|}]/gu, "\\$&")}"`;
|
|
54
|
+
return run.length + 1 + filter.length > WIDTH ? `${run} \\\n ${filter}` : `${run} ${filter}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { GenreTag } from "#genreTags.ts";
|
|
2
|
+
|
|
3
|
+
export interface Genre {
|
|
4
|
+
readonly tag: GenreTag;
|
|
5
|
+
readonly title: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function genreOf(kitName: string): Genre {
|
|
9
|
+
const words = kitName.trim().split(/\s+/u);
|
|
10
|
+
return {
|
|
11
|
+
tag: { description: `stories over a real ${words.join(" ")}`, name: [...words.map((word) => word.toLowerCase()), "story"].join("-") },
|
|
12
|
+
title: [...words, "story"].map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(" "),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
export type AnyEffect = Effect.Effect<unknown, unknown, unknown>;
|
|
4
|
+
|
|
5
|
+
export type Hook<TArguments extends readonly unknown[], TEffect extends AnyEffect, A> = (...input: TArguments) => Generator<TEffect, A, never> | A;
|
|
6
|
+
|
|
7
|
+
export type ErrorOf<TEffect> = [TEffect] extends [never] ? never : [TEffect] extends [Effect.Effect<infer _A, infer E, infer _R>] ? E : never;
|
|
8
|
+
|
|
9
|
+
export type ServicesOf<TEffect> = [TEffect] extends [never] ? never : [TEffect] extends [Effect.Effect<infer _A, infer _E, infer R>] ? R : never;
|
|
10
|
+
|
|
11
|
+
function isGenerator<TEffect, A>(value: Generator<TEffect, A, never> | A): value is Generator<TEffect, A, never> {
|
|
12
|
+
return typeof value === "object" && value !== null && Symbol.iterator in value && "next" in value && typeof value.next === "function";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hooked<TEffect extends AnyEffect, A>(
|
|
16
|
+
call: () => Generator<TEffect, A, never> | A,
|
|
17
|
+
): Effect.Effect<A, ErrorOf<TEffect>, ServicesOf<TEffect>> {
|
|
18
|
+
return Effect.suspend(() => {
|
|
19
|
+
const result = call();
|
|
20
|
+
return isGenerator(result) ? Effect.gen(() => result) : Effect.succeed(result);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { TestContext } from "vitest";
|
|
2
|
+
import { callSite } from "#internal/callSite.ts";
|
|
3
|
+
import { failureReport } from "#internal/failureReport.ts";
|
|
4
|
+
import { describeFailure } from "#internal/render.ts";
|
|
5
|
+
|
|
6
|
+
export interface Refusal {
|
|
7
|
+
readonly cause: unknown;
|
|
8
|
+
readonly index: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Narration {
|
|
12
|
+
at: Error | undefined;
|
|
13
|
+
given: number;
|
|
14
|
+
readonly lines: string[];
|
|
15
|
+
refused: Refusal | undefined;
|
|
16
|
+
readonly sites: Error[];
|
|
17
|
+
readonly tell: (line: string) => void;
|
|
18
|
+
readonly tellAt: (line: string, site: Error) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Subject<TEngine> {
|
|
22
|
+
readonly engine: TEngine;
|
|
23
|
+
readonly inspect: ((engine: TEngine) => unknown) | undefined;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function narrate<TEngine>(subject: Subject<TEngine>, { onTestFailed }: Pick<TestContext, "onTestFailed">): Narration {
|
|
28
|
+
const narration: Narration = {
|
|
29
|
+
at: undefined,
|
|
30
|
+
given: 0,
|
|
31
|
+
lines: [],
|
|
32
|
+
refused: undefined,
|
|
33
|
+
sites: [],
|
|
34
|
+
tell: (line) => {
|
|
35
|
+
narration.tellAt(line, callSite());
|
|
36
|
+
},
|
|
37
|
+
tellAt: (line, site) => {
|
|
38
|
+
narration.lines.push(line);
|
|
39
|
+
narration.sites.push(site);
|
|
40
|
+
narration.at = site;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
onTestFailed(({ task }) => {
|
|
44
|
+
const failure = task.result?.errors?.[0];
|
|
45
|
+
if (failure !== undefined) {
|
|
46
|
+
failure.message = `${failure.message === "" ? describeFailure(failure) : failure.message}\n\n${failureReport(narration, subject, { file: task.file.filepath, name: task.name, stack: failure.stack })}`;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return narration;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function tellGiven(narration: Narration, given: readonly { readonly line: string; readonly site: Error }[]): void {
|
|
53
|
+
for (const part of given) {
|
|
54
|
+
narration.tellAt(part.line, part.site);
|
|
55
|
+
}
|
|
56
|
+
narration.given = given.length;
|
|
57
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const OWN_ERROR_KEYS = new Set(["_tag", "cause", "message", "name", "stack"]);
|
|
2
|
+
|
|
3
|
+
// Vitest serializes a failure's class into these keys.
|
|
4
|
+
const SERIALIZED_KEYS = new Set(["constructor", "toString"]);
|
|
5
|
+
|
|
6
|
+
export function describeValue(value: unknown): string {
|
|
7
|
+
if (typeof value === "string") {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
if (value instanceof Error) {
|
|
11
|
+
return describeError(value);
|
|
12
|
+
}
|
|
13
|
+
return typeof value === "object" && value !== null ? toJson(value) : String(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function describeError(error: Error): string {
|
|
17
|
+
const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : error.name;
|
|
18
|
+
return describeTagged(tag, error.message, Object.entries(error));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function describeFailure(failure: { readonly _tag?: unknown; readonly message: string; readonly name?: string }): string {
|
|
22
|
+
const tag = typeof failure._tag === "string" ? failure._tag : (failure.name ?? "Error");
|
|
23
|
+
return describeTagged(
|
|
24
|
+
tag,
|
|
25
|
+
failure.message,
|
|
26
|
+
Object.entries(failure).filter(([key]) => !SERIALIZED_KEYS.has(key)),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function describeTagged(tag: string, message: string, entries: readonly (readonly [string, unknown])[]): string {
|
|
31
|
+
const fields = Object.fromEntries(entries.filter(([key]) => !OWN_ERROR_KEYS.has(key)));
|
|
32
|
+
const label = tag === "Error" ? "" : tag;
|
|
33
|
+
const detail = Object.keys(fields).length === 0 ? "" : toJson(fields);
|
|
34
|
+
if (message === "") {
|
|
35
|
+
return [label, detail].filter((part) => part !== "").join(" ") || "Error";
|
|
36
|
+
}
|
|
37
|
+
return [label === "" ? message : `${label}: ${message}`, detail].filter((part) => part !== "").join(" ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toJson(value: unknown, indent?: number): string {
|
|
41
|
+
const seen = new WeakSet<object>();
|
|
42
|
+
const json = JSON.stringify(
|
|
43
|
+
value,
|
|
44
|
+
(_key, item: unknown) => {
|
|
45
|
+
if (typeof item === "bigint") {
|
|
46
|
+
return `${item}n`;
|
|
47
|
+
}
|
|
48
|
+
if (typeof item === "symbol") {
|
|
49
|
+
return item.toString();
|
|
50
|
+
}
|
|
51
|
+
if (item instanceof Error) {
|
|
52
|
+
return describeError(item);
|
|
53
|
+
}
|
|
54
|
+
if (typeof item !== "object" || item === null) {
|
|
55
|
+
return item;
|
|
56
|
+
}
|
|
57
|
+
if (seen.has(item)) {
|
|
58
|
+
return "[shown above]";
|
|
59
|
+
}
|
|
60
|
+
seen.add(item);
|
|
61
|
+
if (item instanceof Map) {
|
|
62
|
+
return Object.fromEntries([...item].map(([key, entry]: readonly [unknown, unknown]) => [describeValue(key), entry]));
|
|
63
|
+
}
|
|
64
|
+
return item instanceof Set ? [...item] : item;
|
|
65
|
+
},
|
|
66
|
+
indent,
|
|
67
|
+
);
|
|
68
|
+
return json ?? String(value);
|
|
69
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { broken, cannotRun, unfinished } from "#internal/errors.ts";
|
|
3
|
+
import { type AnyEffect, type Hook, hooked } from "#internal/hooked.ts";
|
|
4
|
+
import type { RunUntilOptions } from "#storyKit.ts";
|
|
5
|
+
|
|
6
|
+
export interface LooseRunHooks<TEngine> {
|
|
7
|
+
readonly diagnose?: Hook<[engine: TEngine], AnyEffect, string>;
|
|
8
|
+
readonly failed?: Hook<[engine: TEngine], AnyEffect, string | undefined>;
|
|
9
|
+
readonly maxSteps: number;
|
|
10
|
+
readonly step: Hook<[engine: TEngine, tell: (line: string) => void], AnyEffect, void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Running<TEngine> {
|
|
14
|
+
readonly engine: TEngine;
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly run: LooseRunHooks<TEngine> | undefined;
|
|
17
|
+
readonly tell: (line: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const runUntil = Effect.fnUntraced(function* <TEngine>(
|
|
21
|
+
{ engine, name, run, tell }: Running<TEngine>,
|
|
22
|
+
until: Hook<[engine: TEngine], AnyEffect, boolean>,
|
|
23
|
+
options: RunUntilOptions | undefined,
|
|
24
|
+
) {
|
|
25
|
+
if (run === undefined) {
|
|
26
|
+
return yield* Effect.die(cannotRun(name));
|
|
27
|
+
}
|
|
28
|
+
const { diagnose, failed, step } = run;
|
|
29
|
+
const maxSteps = options?.maxSteps ?? run.maxSteps;
|
|
30
|
+
for (let steps = 0; ; steps += 1) {
|
|
31
|
+
const reason = failed === undefined ? undefined : yield* hooked(() => failed(engine));
|
|
32
|
+
if (reason !== undefined) {
|
|
33
|
+
return yield* Effect.die(broken(name, steps, reason));
|
|
34
|
+
}
|
|
35
|
+
if (yield* hooked(() => until(engine))) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (steps >= maxSteps) {
|
|
39
|
+
return yield* Effect.die(unfinished(name, steps, diagnose === undefined ? undefined : yield* hooked(() => diagnose(engine))));
|
|
40
|
+
}
|
|
41
|
+
yield* hooked(() => step(engine, tell));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Cause, Effect, type Scope } from "effect";
|
|
2
|
+
import type { AnyTestLayer } from "@shivaedev/effect-test/any-test-layer.ts";
|
|
3
|
+
import type { EffectClock } from "@shivaedev/effect-test/types.ts";
|
|
4
|
+
import { refusal } from "#internal/errors.ts";
|
|
5
|
+
import { type AnyEffect, type Hook, hooked } from "#internal/hooked.ts";
|
|
6
|
+
import { type Narration, tellGiven } from "#internal/narration.ts";
|
|
7
|
+
import type { LooseRunHooks } from "#internal/runUntil.ts";
|
|
8
|
+
import type { Parts } from "#internal/traits.ts";
|
|
9
|
+
import type { RunUntilOptions } from "#storyKit.ts";
|
|
10
|
+
|
|
11
|
+
export type LooseTrait = Parts<string, (engine: unknown) => Effect.Effect<void, unknown, unknown>>;
|
|
12
|
+
|
|
13
|
+
export interface LooseStory {
|
|
14
|
+
readonly engine: unknown;
|
|
15
|
+
readonly lines: readonly string[];
|
|
16
|
+
readonly runUntil: (until: Hook<[engine: unknown], AnyEffect, boolean>, options?: RunUntilOptions) => Effect.Effect<void, unknown, unknown>;
|
|
17
|
+
readonly tell: (line: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LooseDefinition {
|
|
21
|
+
readonly after?: Readonly<Partial<Record<string, Hook<[engine: unknown], AnyEffect, void>>>>;
|
|
22
|
+
readonly around?: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, unknown, R | Scope.Scope>;
|
|
23
|
+
readonly clock?: EffectClock;
|
|
24
|
+
readonly create: Hook<[], AnyEffect, unknown>;
|
|
25
|
+
readonly inspect?: (engine: unknown) => unknown;
|
|
26
|
+
readonly layer?: AnyTestLayer;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly run?: LooseRunHooks<unknown>;
|
|
29
|
+
readonly stages: readonly string[];
|
|
30
|
+
readonly verbs: (engine: unknown, story: LooseStory) => object;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const seed = Effect.fnUntraced(function* (definition: LooseDefinition, engine: unknown, narration: Narration, given: readonly LooseTrait[]) {
|
|
34
|
+
const parts = given.flatMap((each) => each.parts);
|
|
35
|
+
tellGiven(narration, parts);
|
|
36
|
+
for (const stage of definition.stages) {
|
|
37
|
+
for (const [index, part] of parts.entries()) {
|
|
38
|
+
if (part.stage === stage) {
|
|
39
|
+
yield* Effect.suspend(() => part.apply(engine)).pipe(
|
|
40
|
+
Effect.catchCause((cause) => {
|
|
41
|
+
if (Cause.hasInterruptsOnly(cause)) {
|
|
42
|
+
return Effect.interrupt;
|
|
43
|
+
}
|
|
44
|
+
const thrown = Cause.squash(cause);
|
|
45
|
+
narration.refused = { cause: thrown, index };
|
|
46
|
+
return Effect.die(refusal(definition.name, part.line, thrown));
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const after = definition.after?.[stage];
|
|
52
|
+
if (after !== undefined) {
|
|
53
|
+
yield* hooked(() => after(engine));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { TestContext } from "vitest";
|
|
3
|
+
import { callSite } from "#internal/callSite.ts";
|
|
4
|
+
import { hooked } from "#internal/hooked.ts";
|
|
5
|
+
import { narrate } from "#internal/narration.ts";
|
|
6
|
+
import { runUntil } from "#internal/runUntil.ts";
|
|
7
|
+
import { type LooseDefinition, type LooseStory, type LooseTrait, seed } from "#internal/seed.ts";
|
|
8
|
+
|
|
9
|
+
export const start = Effect.fnUntraced(function* (definition: LooseDefinition, given: readonly LooseTrait[], context: TestContext) {
|
|
10
|
+
const engine = yield* hooked(definition.create);
|
|
11
|
+
const narration = narrate({ engine, inspect: definition.inspect, name: definition.name }, context);
|
|
12
|
+
yield* seed(definition, engine, narration, given);
|
|
13
|
+
const story: LooseStory = {
|
|
14
|
+
engine,
|
|
15
|
+
lines: narration.lines,
|
|
16
|
+
runUntil: (until, options) => {
|
|
17
|
+
const site = callSite();
|
|
18
|
+
narration.at = site;
|
|
19
|
+
return runUntil({ engine, name: definition.name, run: definition.run, tell: (line) => narration.tellAt(line, site) }, until, options);
|
|
20
|
+
},
|
|
21
|
+
tell: narration.tell,
|
|
22
|
+
};
|
|
23
|
+
return { ...definition.verbs(engine, story), story };
|
|
24
|
+
});
|