@shivaedev/test-story 0.0.0 → 0.1.0

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +247 -1
  4. package/dist/genreTags.d.ts +6 -0
  5. package/dist/genreTags.d.ts.map +1 -0
  6. package/dist/genreTags.js +5 -0
  7. package/dist/genreTags.js.map +1 -0
  8. package/dist/internal/callSite.d.ts +3 -0
  9. package/dist/internal/callSite.d.ts.map +1 -0
  10. package/dist/internal/callSite.js +16 -0
  11. package/dist/internal/callSite.js.map +1 -0
  12. package/dist/internal/engineSnapshot.d.ts +4 -0
  13. package/dist/internal/engineSnapshot.d.ts.map +1 -0
  14. package/dist/internal/engineSnapshot.js +35 -0
  15. package/dist/internal/engineSnapshot.js.map +1 -0
  16. package/dist/internal/errors.d.ts +5 -0
  17. package/dist/internal/errors.d.ts.map +1 -0
  18. package/dist/internal/errors.js +15 -0
  19. package/dist/internal/errors.js.map +1 -0
  20. package/dist/internal/failureReport.d.ts +8 -0
  21. package/dist/internal/failureReport.d.ts.map +1 -0
  22. package/dist/internal/failureReport.js +40 -0
  23. package/dist/internal/failureReport.js.map +1 -0
  24. package/dist/internal/genre.d.ts +7 -0
  25. package/dist/internal/genre.d.ts.map +1 -0
  26. package/dist/internal/genre.js +8 -0
  27. package/dist/internal/genre.js.map +1 -0
  28. package/dist/internal/hooked.d.ts +7 -0
  29. package/dist/internal/hooked.d.ts.map +1 -0
  30. package/dist/internal/hooked.js +11 -0
  31. package/dist/internal/hooked.js.map +1 -0
  32. package/dist/internal/narration.d.ts +25 -0
  33. package/dist/internal/narration.d.ts.map +1 -0
  34. package/dist/internal/narration.js +34 -0
  35. package/dist/internal/narration.js.map +1 -0
  36. package/dist/internal/render.d.ts +8 -0
  37. package/dist/internal/render.d.ts.map +1 -0
  38. package/dist/internal/render.js +56 -0
  39. package/dist/internal/render.js.map +1 -0
  40. package/dist/internal/runUntil.d.ts +17 -0
  41. package/dist/internal/runUntil.d.ts.map +1 -0
  42. package/dist/internal/runUntil.js +24 -0
  43. package/dist/internal/runUntil.js.map +1 -0
  44. package/dist/internal/seed.d.ts +26 -0
  45. package/dist/internal/seed.d.ts.map +1 -0
  46. package/dist/internal/seed.js +27 -0
  47. package/dist/internal/seed.js.map +1 -0
  48. package/dist/internal/start.d.ts +7 -0
  49. package/dist/internal/start.d.ts.map +1 -0
  50. package/dist/internal/start.js +23 -0
  51. package/dist/internal/start.js.map +1 -0
  52. package/dist/internal/storyIt.d.ts +16 -0
  53. package/dist/internal/storyIt.d.ts.map +1 -0
  54. package/dist/internal/storyIt.js +46 -0
  55. package/dist/internal/storyIt.js.map +1 -0
  56. package/dist/internal/traits.d.ts +12 -0
  57. package/dist/internal/traits.d.ts.map +1 -0
  58. package/dist/internal/traits.js +8 -0
  59. package/dist/internal/traits.js.map +1 -0
  60. package/dist/internal/wrap.d.ts +8 -0
  61. package/dist/internal/wrap.d.ts.map +1 -0
  62. package/dist/internal/wrap.js +49 -0
  63. package/dist/internal/wrap.js.map +1 -0
  64. package/dist/storyKit.d.ts +65 -0
  65. package/dist/storyKit.d.ts.map +1 -0
  66. package/dist/storyKit.js +11 -0
  67. package/dist/storyKit.js.map +1 -0
  68. package/docs/north-star.md +35 -0
  69. package/docs/roadmap.md +18 -0
  70. package/package.json +67 -3
  71. package/src/genreTags.ts +10 -0
  72. package/src/internal/callSite.ts +18 -0
  73. package/src/internal/engineSnapshot.ts +47 -0
  74. package/src/internal/errors.ts +39 -0
  75. package/src/internal/failureReport.ts +54 -0
  76. package/src/internal/genre.ts +14 -0
  77. package/src/internal/hooked.ts +22 -0
  78. package/src/internal/narration.ts +57 -0
  79. package/src/internal/render.ts +69 -0
  80. package/src/internal/runUntil.ts +43 -0
  81. package/src/internal/seed.ts +53 -0
  82. package/src/internal/start.ts +24 -0
  83. package/src/internal/storyIt.ts +63 -0
  84. package/src/internal/traits.ts +20 -0
  85. package/src/internal/wrap.ts +50 -0
  86. package/src/storyKit.ts +126 -0
  87. package/login.log +0 -26
  88. package/publish.log +0 -0
  89. package/run.out +0 -26
  90. package/run.sh +0 -10
package/package.json CHANGED
@@ -1,11 +1,75 @@
1
1
  {
2
2
  "name": "@shivaedev/test-story",
3
- "version": "0.0.0",
4
- "description": "Placeholder. Story DSL for tests: domain traits, a staged seed, a story log and a settle loop.",
5
- "license": "MIT",
3
+ "version": "0.1.0",
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.1"
47
+ },
48
+ "devDependencies": {
49
+ "@effect/vitest": "4.0.0-rc.112",
50
+ "@shivaedev/quality": "0.17.0",
51
+ "@types/node": "24.10.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 }))\" && tsc6 --project tsconfig.build.json",
71
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
72
+ "test": "vitest run",
73
+ "typecheck": "tsc --noEmit"
10
74
  }
11
75
  }
@@ -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,18 @@
1
+ import { relative } from "node:path";
2
+
3
+ const FRAMES = 50;
4
+
5
+ // V8 formats a stack only when it is read, so a site costs little until a test fails.
6
+ export function callSite(): Error {
7
+ const limit = Error.stackTraceLimit;
8
+ Error.stackTraceLimit = FRAMES;
9
+ const site = new Error("call site");
10
+ Error.stackTraceLimit = limit;
11
+ return site;
12
+ }
13
+
14
+ export function locate(stack: string | undefined, testFile: string): string | undefined {
15
+ const frame = stack?.split("\n").find((line) => line.includes(testFile));
16
+ const position = frame?.slice(frame.indexOf(testFile) + testFile.length).match(/^:(?<line>\d+):(?<column>\d+)/u)?.groups;
17
+ return position === undefined ? undefined : `${relative(process.cwd(), testFile)}:${position.line}:${position.column}`;
18
+ }
@@ -0,0 +1,47 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative } from "node:path";
3
+ import type { FailedTest } from "#internal/failureReport.ts";
4
+ import type { Subject } from "#internal/narration.ts";
5
+ import { describeValue, toJson } from "#internal/render.ts";
6
+ import { WIDTH, wrap, wrapJson } from "#internal/wrap.ts";
7
+
8
+ const INLINE_CHARACTERS = 2000;
9
+
10
+ type Snapshot = { readonly value: unknown } | { readonly threw: unknown };
11
+
12
+ function snapshot<TEngine>(subject: Subject<TEngine>): Snapshot {
13
+ try {
14
+ return { value: subject.inspect === undefined ? subject.engine : subject.inspect(subject.engine) };
15
+ } catch (threw) {
16
+ return { threw };
17
+ }
18
+ }
19
+
20
+ function slug(text: string): string {
21
+ return text
22
+ .toLowerCase()
23
+ .replaceAll(/[^a-z0-9.]+/gu, "-")
24
+ .slice(0, 80);
25
+ }
26
+
27
+ export function engineSnapshot<TEngine>(subject: Subject<TEngine>, test: FailedTest): string[] {
28
+ const heading = `The ${subject.name} when the test failed`;
29
+ const taken = snapshot(subject);
30
+ if ("threw" in taken) {
31
+ return wrap(`${heading} is unknown: its inspect hook threw ${describeValue(taken.threw)}`);
32
+ }
33
+ const json = toJson(taken.value);
34
+ if (json.length <= INLINE_CHARACTERS) {
35
+ return heading.length + 2 + json.length > WIDTH ? [`${heading}:`, ...wrapJson(json)] : [`${heading}: ${json}`];
36
+ }
37
+ const file = join(
38
+ process.cwd(),
39
+ "node_modules",
40
+ ".cache",
41
+ "test-story",
42
+ `${slug(basename(test.file))}--${slug(test.name)}--${slug(subject.name)}.json`,
43
+ );
44
+ mkdirSync(dirname(file), { recursive: true });
45
+ writeFileSync(file, toJson(taken.value, 2));
46
+ return wrap(`${heading} is ${json.length} characters of JSON, too long to print here. Read it in ${relative(process.cwd(), file)}`);
47
+ }
@@ -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,54 @@
1
+ import { relative } from "node:path";
2
+ import { locate } from "#internal/callSite.ts";
3
+ import { engineSnapshot } from "#internal/engineSnapshot.ts";
4
+ import type { Narration, Subject } from "#internal/narration.ts";
5
+ import { describeValue } from "#internal/render.ts";
6
+ import { WIDTH, wrap } from "#internal/wrap.ts";
7
+
8
+ export interface FailedTest {
9
+ readonly file: string;
10
+ readonly name: string;
11
+ readonly stack: string | undefined;
12
+ }
13
+
14
+ export function failureReport<TEngine>(narration: Narration, subject: Subject<TEngine>, test: FailedTest): string {
15
+ try {
16
+ return [...guide(subject.name), "", ...storyRows(narration, test), "", ...engineSnapshot(subject, test), "", rerun(test)].join("\n");
17
+ } catch (error) {
18
+ return `test-story could not print the story of this ${subject.name}: ${describeValue(error)}`;
19
+ }
20
+ }
21
+
22
+ function guide(name: string): string[] {
23
+ return [
24
+ "╭─ test-story: how to read the story below",
25
+ ...wrap(
26
+ `"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.`,
27
+ { first: "│ ", rest: "│ " },
28
+ ),
29
+ "╰─",
30
+ ];
31
+ }
32
+
33
+ function storyRows(narration: Narration, test: FailedTest): string[] {
34
+ const rows = narration.lines.map(
35
+ (line, index) => `${narration.refused?.index === index ? "✗" : " "} ${index < narration.given ? "given" : " "} ${line}`,
36
+ );
37
+ if (narration.refused !== undefined) {
38
+ const where = locate(narration.sites[narration.refused.index]?.stack, test.file);
39
+ rows.splice(narration.refused.index + 1, 0, ...(where === undefined ? [] : [` refused at ${where}`]));
40
+ return rows;
41
+ }
42
+ const where = locate(test.stack, test.file) ?? locate(narration.at?.stack, test.file);
43
+ return [
44
+ ...rows,
45
+ `✗ the test failed ${rows.length === 0 ? "before the story told a line" : "after the line above"}`,
46
+ ...(where === undefined ? [] : [` at ${where}`]),
47
+ ];
48
+ }
49
+
50
+ function rerun(test: FailedTest): string {
51
+ const run = `Rerun: vitest run ${relative(process.cwd(), test.file)}`;
52
+ const filter = `-t "${test.name.replaceAll(/[$()*+.?[\\\]^{|}]/gu, "\\$&")}"`;
53
+ return run.length + 1 + filter.length > WIDTH ? `${run} \\\n ${filter}` : `${run} ${filter}`;
54
+ }
@@ -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,53 @@
1
+ import { Cause, Effect } from "effect";
2
+ import type { AnyTestLayer } from "@shivaedev/effect-test/any-test-layer.ts";
3
+ import { refusal } from "#internal/errors.ts";
4
+ import { type AnyEffect, type Hook, hooked } from "#internal/hooked.ts";
5
+ import { type Narration, tellGiven } from "#internal/narration.ts";
6
+ import type { LooseRunHooks } from "#internal/runUntil.ts";
7
+ import type { Parts } from "#internal/traits.ts";
8
+ import type { RunUntilOptions } from "#storyKit.ts";
9
+
10
+ export type LooseTrait = Parts<string, (engine: unknown) => Effect.Effect<void, unknown, unknown>>;
11
+
12
+ export interface LooseStory {
13
+ readonly engine: unknown;
14
+ readonly lines: readonly string[];
15
+ readonly runUntil: (until: Hook<[engine: unknown], AnyEffect, boolean>, options?: RunUntilOptions) => Effect.Effect<void, unknown, unknown>;
16
+ readonly tell: (line: string) => void;
17
+ }
18
+
19
+ export interface LooseDefinition {
20
+ readonly after?: Readonly<Partial<Record<string, Hook<[engine: unknown], AnyEffect, void>>>>;
21
+ readonly create: Hook<[], AnyEffect, unknown>;
22
+ readonly inspect?: (engine: unknown) => unknown;
23
+ readonly layer?: AnyTestLayer;
24
+ readonly name: string;
25
+ readonly run?: LooseRunHooks<unknown>;
26
+ readonly stages: readonly string[];
27
+ readonly verbs: (engine: unknown, story: LooseStory) => object;
28
+ }
29
+
30
+ export const seed = Effect.fnUntraced(function* (definition: LooseDefinition, engine: unknown, narration: Narration, given: readonly LooseTrait[]) {
31
+ const parts = given.flatMap((each) => each.parts);
32
+ tellGiven(narration, parts);
33
+ for (const stage of definition.stages) {
34
+ for (const [index, part] of parts.entries()) {
35
+ if (part.stage === stage) {
36
+ yield* Effect.suspend(() => part.apply(engine)).pipe(
37
+ Effect.catchCause((cause) => {
38
+ if (Cause.hasInterruptsOnly(cause)) {
39
+ return Effect.interrupt;
40
+ }
41
+ const thrown = Cause.squash(cause);
42
+ narration.refused = { cause: thrown, index };
43
+ return Effect.die(refusal(definition.name, part.line, thrown));
44
+ }),
45
+ );
46
+ }
47
+ }
48
+ const after = definition.after?.[stage];
49
+ if (after !== undefined) {
50
+ yield* hooked(() => after(engine));
51
+ }
52
+ }
53
+ });
@@ -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
+ });
@@ -0,0 +1,63 @@
1
+ import type { TestContext } from "@effect/vitest";
2
+ import { Effect, Layer } from "effect";
3
+ import type { EffectTest, EffectTestOptions } from "@shivaedev/effect-test/types.ts";
4
+ import { makeEffectIt } from "@shivaedev/effect-test/vitest.ts";
5
+ import { type Genre, genreOf } from "#internal/genre.ts";
6
+ import { type AnyEffect, hooked } from "#internal/hooked.ts";
7
+ import type { LooseDefinition, LooseTrait } from "#internal/seed.ts";
8
+ import { start } from "#internal/start.ts";
9
+
10
+ type LooseBody = (told: object, context: TestContext) => Generator<AnyEffect, unknown, never> | undefined;
11
+
12
+ type LooseTest = (name: string, given: readonly LooseTrait[], body?: LooseBody, options?: number | EffectTestOptions) => void;
13
+
14
+ export interface LooseIt extends LooseTest {
15
+ readonly fails: LooseTest;
16
+ readonly only: LooseTest;
17
+ readonly runIf: (condition: unknown) => LooseTest;
18
+ readonly skip: LooseTest;
19
+ readonly skipIf: (condition: unknown) => LooseTest;
20
+ }
21
+
22
+ function withTags(options: number | EffectTestOptions | undefined, tags: readonly string[]): EffectTestOptions {
23
+ if (typeof options === "number") {
24
+ return { tags: [...tags], timeout: options };
25
+ }
26
+ const own = options?.tags ?? [];
27
+ return { ...options, tags: [...tags, ...(typeof own === "string" ? [own] : own)] };
28
+ }
29
+
30
+ function storyOf(definition: LooseDefinition, given: readonly LooseTrait[], body: LooseBody | undefined) {
31
+ return function* story(_harness: unknown, context: TestContext) {
32
+ const told = yield* start(definition, given, context);
33
+ return yield* hooked(() => body?.(told, context));
34
+ };
35
+ }
36
+
37
+ // Vitest refuses a tag its config does not declare, so a genre the config leaves out names the test without tagging it.
38
+ function declaring(definition: LooseDefinition, genre: Genre, declare: EffectTest<unknown, unknown>): LooseTest {
39
+ return (name, given, body, options) => {
40
+ const title = `${genre.title}: ${name}`;
41
+ const story = storyOf(definition, given, body);
42
+ try {
43
+ declare(title, story, withTags(options, [genre.tag.name]));
44
+ } catch (error) {
45
+ if (!(error instanceof Error && error.message.includes(`"${genre.tag.name}"`))) {
46
+ throw error;
47
+ }
48
+ declare(title, story, withTags(options, []));
49
+ }
50
+ };
51
+ }
52
+
53
+ export function storyIt(definition: LooseDefinition): LooseIt {
54
+ const genre = genreOf(definition.name);
55
+ const { effectApp } = makeEffectIt({ layer: definition.layer ?? Layer.empty, makeHarness: () => Effect.void });
56
+ return Object.assign(declaring(definition, genre, effectApp), {
57
+ fails: declaring(definition, genre, effectApp.fails),
58
+ only: declaring(definition, genre, effectApp.only),
59
+ runIf: (condition: unknown) => declaring(definition, genre, effectApp.runIf(condition)),
60
+ skip: declaring(definition, genre, effectApp.skip),
61
+ skipIf: (condition: unknown) => declaring(definition, genre, effectApp.skipIf(condition)),
62
+ });
63
+ }
@@ -0,0 +1,20 @@
1
+ import { callSite } from "#internal/callSite.ts";
2
+
3
+ export interface TraitPart<TStage extends string, TApply> {
4
+ readonly apply: TApply;
5
+ readonly line: string;
6
+ readonly site: Error;
7
+ readonly stage: TStage;
8
+ }
9
+
10
+ export interface Parts<TStage extends string, TApply> {
11
+ readonly parts: readonly TraitPart<TStage, TApply>[];
12
+ }
13
+
14
+ export function trait<TStage extends string, TApply>(stage: TStage, line: string, apply: TApply): Parts<TStage, TApply> {
15
+ return { parts: [{ apply, line, site: callSite(), stage }] };
16
+ }
17
+
18
+ export function traits<TStage extends string, TApply>(...given: readonly Parts<TStage, TApply>[]): Parts<TStage, TApply> {
19
+ return { parts: given.flatMap((each) => each.parts) };
20
+ }