@uniflowed/story 0.0.0-alpha.18
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/collect.js +279 -0
- package/index.js +160 -0
- package/package.json +34 -0
- package/play.js +173 -0
- package/render.js +211 -0
- package/runner.js +93 -0
- package/story.js +293 -0
package/collect.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/story/collect`: which files are stories, and what is in them.
|
|
4
|
+
//
|
|
5
|
+
// A story file is `_uf.story.js`, beside the component it describes.
|
|
6
|
+
//
|
|
7
|
+
// src/components/Button.js
|
|
8
|
+
// src/components/_uf.story.js
|
|
9
|
+
//
|
|
10
|
+
// # The name is the repository's own grammar, not a second one
|
|
11
|
+
//
|
|
12
|
+
// uf already reserves `_uf.<role>[.<variant>].js` for the files the framework
|
|
13
|
+
// gives meaning to, and `crates/uf_router/src/reserved.rs` is its single
|
|
14
|
+
// source of truth: `uf create` generates those names, the router looks for
|
|
15
|
+
// them, and `uf lint`'s `router/reserved-files` rejects the ones that do not
|
|
16
|
+
// fit. A story file is exactly such a file — a name uf assigns meaning to, in
|
|
17
|
+
// a project's own source tree — so it is spelled `_uf.story.js` and not
|
|
18
|
+
// `*.stories.js`.
|
|
19
|
+
//
|
|
20
|
+
// The variants are the same vocabulary for the same reason, and the rule
|
|
21
|
+
// about them is the router's too: only the default variant is the thing the
|
|
22
|
+
// runner renders. `_uf.story.native.js` is a companion for a React Native
|
|
23
|
+
// build, the way `_uf.page.native.js` is, and [`findStoryFiles`] leaves it
|
|
24
|
+
// alone until there is a renderer that could mount it.
|
|
25
|
+
//
|
|
26
|
+
// **`story` is not yet one of the roles the Rust grammar defines.** Adding it
|
|
27
|
+
// is one arm in `ReservedRole` in `crates/uf_router/src/reserved.rs`, and
|
|
28
|
+
// this package cannot make that change from JavaScript. Until it lands,
|
|
29
|
+
// `uf lint` reports `router/reserved-files` on every `_uf.story.js` — the
|
|
30
|
+
// name is right and the linter has not been told. `index.js` lists it under
|
|
31
|
+
// **Readiness** rather than leaving it to be discovered by whoever writes the
|
|
32
|
+
// first story file.
|
|
33
|
+
//
|
|
34
|
+
// # What makes a file a story file is the name, and what makes it valid is
|
|
35
|
+
// the export
|
|
36
|
+
//
|
|
37
|
+
// Discovery is by name alone: a walk that had to read every file to find out
|
|
38
|
+
// whether it declared stories would be a parse of the whole tree. Loading is
|
|
39
|
+
// where a file is judged, and a `_uf.story.js` that exports no story set is
|
|
40
|
+
// an error rather than an empty result — the name is a claim, and a file that
|
|
41
|
+
// does not honour it is a mistake somebody made, not a fact about the
|
|
42
|
+
// project.
|
|
43
|
+
//
|
|
44
|
+
// Every export is considered, not a conventional name and not the default
|
|
45
|
+
// export. A file may hold the stories of two components, and naming them
|
|
46
|
+
// after what they are is better than naming one of them `default`. uf's
|
|
47
|
+
// `react/no-default-export-component` lint rule points the same way.
|
|
48
|
+
//
|
|
49
|
+
// # Why this walk is in JavaScript, and where it stops being acceptable
|
|
50
|
+
//
|
|
51
|
+
// uf's rule is that repository-wide file discovery belongs in Rust, and it is
|
|
52
|
+
// the right rule: `uf test` and the router both discover natively, and both
|
|
53
|
+
// are on the hot path of every keystroke in watch mode. This walk is not that
|
|
54
|
+
// yet. It is bounded work a test or a script asks for once — one `readdir`
|
|
55
|
+
// per directory, no file read, no parse — and it exists because there is no
|
|
56
|
+
// `uf story` command to discover natively *for* it.
|
|
57
|
+
//
|
|
58
|
+
// When one lands, the walk belongs in `uf_router`-shaped Rust beside the
|
|
59
|
+
// route discovery it mirrors, with this left as the loader the host calls
|
|
60
|
+
// with the paths it was given. [`loadStoryFile`] and [`indexStories`] are
|
|
61
|
+
// already separate from [`findStoryFiles`] for that reason: replacing the
|
|
62
|
+
// walk does not touch them.
|
|
63
|
+
|
|
64
|
+
import { readdir } from "node:fs/promises";
|
|
65
|
+
import path from "node:path";
|
|
66
|
+
import { pathToFileURL } from "node:url";
|
|
67
|
+
|
|
68
|
+
import type { Story, StorySet } from "./story.js";
|
|
69
|
+
import { isStorySet } from "./story.js";
|
|
70
|
+
|
|
71
|
+
/** The role segment a story file carries. */
|
|
72
|
+
export const STORY_ROLE: "story" = "story";
|
|
73
|
+
|
|
74
|
+
/** The name of a story file with no variant: the one the runner renders. */
|
|
75
|
+
export const STORY_FILE: "_uf.story.js" = "_uf.story.js";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which build a story file applies to.
|
|
79
|
+
*
|
|
80
|
+
* The router's vocabulary, exactly. `"default"` has no segment in the name.
|
|
81
|
+
*/
|
|
82
|
+
export type StoryVariant = "default" | "native" | "ios" | "android" | "web" | "test";
|
|
83
|
+
|
|
84
|
+
/** The variants, in the order `uf_router::ReservedVariant` declares them. */
|
|
85
|
+
const VARIANTS: $ReadOnlyArray<StoryVariant> = ["native", "ios", "android", "web", "test"];
|
|
86
|
+
|
|
87
|
+
/** Directory names the walk never descends into. */
|
|
88
|
+
const IGNORED: $ReadOnlyArray<string> = [
|
|
89
|
+
"node_modules",
|
|
90
|
+
".git",
|
|
91
|
+
".uf",
|
|
92
|
+
"dist",
|
|
93
|
+
"build",
|
|
94
|
+
"target",
|
|
95
|
+
"coverage",
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The variant of `fileName` as a story file, or `null` if it is not one.
|
|
100
|
+
*
|
|
101
|
+
* Takes a file name, not a path, for the reason the Rust classifier does:
|
|
102
|
+
* a caller with a path can normalise it two ways and get two answers.
|
|
103
|
+
*/
|
|
104
|
+
export function classifyStoryFile(fileName: string): StoryVariant | null {
|
|
105
|
+
if (!fileName.startsWith("_uf.") || !fileName.endsWith(".js")) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const segments = fileName.slice("_uf.".length, -".js".length).split(".");
|
|
109
|
+
if (segments[0] !== STORY_ROLE) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (segments.length === 1) {
|
|
113
|
+
return "default";
|
|
114
|
+
}
|
|
115
|
+
if (segments.length > 2) {
|
|
116
|
+
// `_uf.story.native.test.js`: one variant, not a stack of them.
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const variant = VARIANTS.find((each) => each === segments[1]);
|
|
120
|
+
return variant ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether `fileName` is the story file a renderer mounts.
|
|
125
|
+
*
|
|
126
|
+
* The platform variants are companions to a story, never stories of their
|
|
127
|
+
* own — the same distinction `ReservedVariant::is_route_entry` draws.
|
|
128
|
+
*/
|
|
129
|
+
export function isStoryEntry(fileName: string): boolean {
|
|
130
|
+
return classifyStoryFile(fileName) === "default";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What to leave out of a walk. */
|
|
134
|
+
export type FindOptions = {|
|
|
135
|
+
/** Directory names to skip, replacing the defaults. */
|
|
136
|
+
readonly ignore?: $ReadOnlyArray<string>,
|
|
137
|
+
/**
|
|
138
|
+
* How deep to descend below `root`. Defaults to 32.
|
|
139
|
+
*
|
|
140
|
+
* A bound rather than a promise not to recurse: a directory tree is
|
|
141
|
+
* untrusted input the moment a generated or vendored directory is in it,
|
|
142
|
+
* and an unbounded walk is an unbounded stack.
|
|
143
|
+
*/
|
|
144
|
+
readonly maxDepth?: number,
|
|
145
|
+
|};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Every story file under `root`, as absolute paths, in a stable order.
|
|
149
|
+
*
|
|
150
|
+
* Sorted, so a catalogue, a report and a set of baselines come out in the
|
|
151
|
+
* same order on every machine.
|
|
152
|
+
*
|
|
153
|
+
* Symbolic links are not followed. That falls out of asking `readdir` for
|
|
154
|
+
* directory entries rather than stating it as a rule: an entry describes the
|
|
155
|
+
* link itself, so a link is neither `isDirectory()` nor `isFile()` and the
|
|
156
|
+
* walk passes it by. It has to be that way round — a link to a parent is a
|
|
157
|
+
* walk that does not terminate, and a link into `node_modules` is somebody
|
|
158
|
+
* else's story file.
|
|
159
|
+
*/
|
|
160
|
+
export async function findStoryFiles(root: string, options?: FindOptions): Promise<Array<string>> {
|
|
161
|
+
const ignore = new Set(options?.ignore ?? IGNORED);
|
|
162
|
+
const maxDepth = options?.maxDepth ?? 32;
|
|
163
|
+
const found: Array<string> = [];
|
|
164
|
+
|
|
165
|
+
const walk = async (directory: string, depth: number): Promise<void> => {
|
|
166
|
+
if (depth > maxDepth) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const entries = (await readdir(directory, { withFileTypes: true })).map((entry) => ({
|
|
170
|
+
entry,
|
|
171
|
+
// Flow's Node library definition types `Dirent.name` as `string |
|
|
172
|
+
// Buffer`, because `readdir` can be asked for buffers. This call does
|
|
173
|
+
// not ask; the conversion is what says so, rather than a cast that
|
|
174
|
+
// would be wrong the day somebody adds an encoding.
|
|
175
|
+
name: typeof entry.name === "string" ? entry.name : entry.name.toString("utf8"),
|
|
176
|
+
}));
|
|
177
|
+
entries.sort((left, right) => (left.name < right.name ? -1 : 1));
|
|
178
|
+
|
|
179
|
+
for (const { entry, name } of entries) {
|
|
180
|
+
if (entry.isDirectory()) {
|
|
181
|
+
if (!ignore.has(name)) {
|
|
182
|
+
await walk(path.join(directory, name), depth + 1);
|
|
183
|
+
}
|
|
184
|
+
} else if (entry.isFile() && isStoryEntry(name)) {
|
|
185
|
+
found.push(path.join(directory, name));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
await walk(path.resolve(root), 0);
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Import `file` and return every story set it exports.
|
|
196
|
+
*
|
|
197
|
+
* Throws when it exports none. See the module docs: the name is a claim.
|
|
198
|
+
*
|
|
199
|
+
* The import is by `file:` URL rather than by path, because a bare path is
|
|
200
|
+
* resolved against the *importer* on every host uf supports, and this module
|
|
201
|
+
* is not where the story file lives.
|
|
202
|
+
*/
|
|
203
|
+
export async function loadStoryFile(file: string): Promise<Array<StorySet>> {
|
|
204
|
+
const absolute = path.resolve(file);
|
|
205
|
+
const module = await import(pathToFileURL(absolute).href);
|
|
206
|
+
const sets: Array<StorySet> = [];
|
|
207
|
+
for (const name of Object.keys(module)) {
|
|
208
|
+
const value = module[name];
|
|
209
|
+
if (isStorySet(value)) {
|
|
210
|
+
sets.push(value);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (sets.length === 0) {
|
|
214
|
+
throw new Error(`@uniflowed/story: ${absolute} exports no story set`);
|
|
215
|
+
}
|
|
216
|
+
return sets;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Every story a project declares, and how to reach one. */
|
|
220
|
+
export type StoryIndex = {|
|
|
221
|
+
/** The files the sets came from, in walk order. */
|
|
222
|
+
readonly files: $ReadOnlyArray<string>,
|
|
223
|
+
/** Every set, in the order its file was found. */
|
|
224
|
+
readonly sets: $ReadOnlyArray<StorySet>,
|
|
225
|
+
/** Every story in every set, flattened, in declaration order. */
|
|
226
|
+
readonly stories: $ReadOnlyArray<Story>,
|
|
227
|
+
/** The story with this id, or `undefined`. */
|
|
228
|
+
readonly get: (id: string) => Story | void,
|
|
229
|
+
|};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build an index from files that have already been found.
|
|
233
|
+
*
|
|
234
|
+
* Separate from [`collectStories`] so that a host which discovered the files
|
|
235
|
+
* some other way — natively, from a watcher, from a changed-files list — can
|
|
236
|
+
* still build the same index.
|
|
237
|
+
*
|
|
238
|
+
* Two stories with one id is an error, and it names both files. An id is what
|
|
239
|
+
* a failing job prints and what a visual baseline is filed under, so a
|
|
240
|
+
* collision does not produce a confusing result: it produces the *wrong* one,
|
|
241
|
+
* silently, for whichever of the two was written second.
|
|
242
|
+
*/
|
|
243
|
+
export async function indexStories(files: $ReadOnlyArray<string>): Promise<StoryIndex> {
|
|
244
|
+
const sets: Array<StorySet> = [];
|
|
245
|
+
const stories: Array<Story> = [];
|
|
246
|
+
const byId: Map<string, Story> = new Map();
|
|
247
|
+
const sources: Map<string, string> = new Map();
|
|
248
|
+
|
|
249
|
+
for (const file of files) {
|
|
250
|
+
for (const set of await loadStoryFile(file)) {
|
|
251
|
+
sets.push(set);
|
|
252
|
+
for (const story of set.stories) {
|
|
253
|
+
const clash = sources.get(story.id);
|
|
254
|
+
if (clash != null) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`@uniflowed/story: two stories share the id ${story.id}\n` +
|
|
257
|
+
` ${clash}\n ${file}\n` +
|
|
258
|
+
"Give one of them a different title or name.",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
sources.set(story.id, file);
|
|
262
|
+
byId.set(story.id, story);
|
|
263
|
+
stories.push(story);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
files: [...files],
|
|
270
|
+
sets,
|
|
271
|
+
stories,
|
|
272
|
+
get: (id: string) => byId.get(id),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Find every story file under `root` and index what they declare. */
|
|
277
|
+
export async function collectStories(root: string, options?: FindOptions): Promise<StoryIndex> {
|
|
278
|
+
return indexStories(await findStoryFiles(root, options));
|
|
279
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/story`: a named state of a component that a person and a test
|
|
4
|
+
// can both reach.
|
|
5
|
+
//
|
|
6
|
+
// uf already has a test runner, a real DOM, queries a person would recognise,
|
|
7
|
+
// and request mocking. So the question this package has to answer is what a
|
|
8
|
+
// story is still worth once all of that exists — and the answer is not "a
|
|
9
|
+
// nicer way to render a component in a test", because `render(<Button />)`
|
|
10
|
+
// was already that.
|
|
11
|
+
//
|
|
12
|
+
// What a story adds is that the state has a **name outside the file that
|
|
13
|
+
// produced it**. `button--pending` is a thing a CI job can print, a visual
|
|
14
|
+
// baseline can be filed under, a URL can carry and a reviewer can ask for.
|
|
15
|
+
// The props and the setup that state needs are declared once, beside the
|
|
16
|
+
// name, instead of being rebuilt in every test that wants it — and because
|
|
17
|
+
// they are declared as data, the same declaration serves the assertion and
|
|
18
|
+
// the picture.
|
|
19
|
+
//
|
|
20
|
+
// // src/components/_uf.story.js
|
|
21
|
+
// export const stories = defineStories({
|
|
22
|
+
// title: "Button",
|
|
23
|
+
// component: Button,
|
|
24
|
+
// props: { label: "Save", pending: false },
|
|
25
|
+
// stories: {
|
|
26
|
+
// Primary: {},
|
|
27
|
+
// Pending: { props: { pending: true } },
|
|
28
|
+
// Saved: {
|
|
29
|
+
// mocks: [http.post("/save", () => HttpResponse.json({ ok: true }))],
|
|
30
|
+
// play: async ({ canvas, user }) => {
|
|
31
|
+
// await user.click(canvas.getByRole("button", { name: "Save" }));
|
|
32
|
+
// if ((await canvas.findByRole("status")).textContent !== "Saved") {
|
|
33
|
+
// throw new Error("the button did not report success");
|
|
34
|
+
// }
|
|
35
|
+
// },
|
|
36
|
+
// },
|
|
37
|
+
// },
|
|
38
|
+
// });
|
|
39
|
+
//
|
|
40
|
+
// That file is three things at once: the catalogue entry a person browses,
|
|
41
|
+
// the fixture a test mounts, and — for `Saved` — a test in its own right.
|
|
42
|
+
//
|
|
43
|
+
// # Why this is Flow now
|
|
44
|
+
//
|
|
45
|
+
// It used to be a declaration whose every function returned
|
|
46
|
+
// `nativeRuntimeRequired(…)` behind an opaque `NativeHandle`, which is to say
|
|
47
|
+
// it was a contract with nothing behind it. Nothing here needs to be native
|
|
48
|
+
// and nothing here would be faster if it were: declaring a story builds a
|
|
49
|
+
// closure, rendering one is React's work in a DOM, and playing one is the
|
|
50
|
+
// same event dispatch `@uniflowed/react-testing` already does. The one part
|
|
51
|
+
// of a story system that *is* a hot path — walking a repository to find every
|
|
52
|
+
// story file — is discussed under `collect.js`, which says plainly that the
|
|
53
|
+
// walk belongs in Rust when there is a `uf story` command to own it.
|
|
54
|
+
//
|
|
55
|
+
// So the handle is gone rather than preserved. A story is a value: it can be
|
|
56
|
+
// built, passed, filtered and rendered by ordinary code, which is what makes
|
|
57
|
+
// the same declaration reachable from a test, from a page and from a script.
|
|
58
|
+
//
|
|
59
|
+
// # How the package is laid out
|
|
60
|
+
//
|
|
61
|
+
// Five modules beside this one, split by what each decides:
|
|
62
|
+
//
|
|
63
|
+
// - `story.js` — **declaring**: `defineStories`, what a story inherits from
|
|
64
|
+
// its set, and where the `Props` type parameter is checked and why it is
|
|
65
|
+
// then erased. Pure data; declaring a story runs nothing.
|
|
66
|
+
// - `collect.js` — **finding**: `_uf.story.js`, the repository's own reserved
|
|
67
|
+
// name grammar, the walk, and what makes a story file valid.
|
|
68
|
+
// - `render.js` — **rendering one**: the mock lifetime, the decorators, the
|
|
69
|
+
// mount, and the markup. Everything here is about *time*.
|
|
70
|
+
// - `play.js` — **driving one**: what a play function is handed, what a step
|
|
71
|
+
// means, and what a failure inside one says.
|
|
72
|
+
// - `runner.js` — **`@uniflowed/test`**: one test per story. A separate entry
|
|
73
|
+
// point, so a consumer that does not want the test runner never resolves
|
|
74
|
+
// it. Not re-exported below, for the same reason.
|
|
75
|
+
//
|
|
76
|
+
// There is no `internal/`. Every module here is a reasonable thing to import
|
|
77
|
+
// on purpose: a documentation build wants `collect.js` and `render.js` and
|
|
78
|
+
// has no use for the runner, and a test wants the runner and never walks a
|
|
79
|
+
// directory.
|
|
80
|
+
//
|
|
81
|
+
// # Readiness
|
|
82
|
+
//
|
|
83
|
+
// **Implemented and tested.** Declaring a component's stories with complete
|
|
84
|
+
// props on the set and a partial override per story, with per-story and
|
|
85
|
+
// per-set decorators, mocks and play functions; names defaulting to the
|
|
86
|
+
// declaration key; stable `title--name` ids. Collecting them: the
|
|
87
|
+
// `_uf.story.js` reserved name with the router's variant vocabulary, a
|
|
88
|
+
// bounded walk that skips `node_modules` and symlinks, loading every story
|
|
89
|
+
// set a file exports, and rejecting two stories that share an id. Rendering
|
|
90
|
+
// one into `@uniflowed/react-testing`'s DOM, with the story's handlers
|
|
91
|
+
// listening before the mount and `globalThis.fetch` put back on unmount, and
|
|
92
|
+
// with the recorded requests exposed. Play functions with a scoped `canvas`,
|
|
93
|
+
// `userEvent`, named steps, and a failure that carries the story id, the step
|
|
94
|
+
// path and the original error. `renderStoryToHtml` for something that is not
|
|
95
|
+
// a test. One `it` per story through `@uniflowed/story/runner`.
|
|
96
|
+
//
|
|
97
|
+
// **Experimental.** The `_uf.story.js` name itself. It follows uf's reserved
|
|
98
|
+
// grammar — `_uf.<role>[.<variant>].js` — but `story` is not yet one of the
|
|
99
|
+
// roles `crates/uf_router/src/reserved.rs` defines, and that file is the
|
|
100
|
+
// grammar's single source of truth for `uf create`, the router and the
|
|
101
|
+
// linter. Until a `story` role is added there, **`uf lint` reports
|
|
102
|
+
// `router/reserved-files` on every `_uf.story.js`**: the name is right and
|
|
103
|
+
// the linter has not been told. The alternative was to invent a second
|
|
104
|
+
// convention (`*.stories.js`) that no uf tool knows about, which is worse.
|
|
105
|
+
//
|
|
106
|
+
// `describeStories` is experimental for a different reason, and it is a
|
|
107
|
+
// property of `uf test` rather than of this package: discovery scans source
|
|
108
|
+
// text for `it(` with a string-literal name, so a file whose only content is
|
|
109
|
+
// a `describeStories` call is skipped — silently, reporting zero files and
|
|
110
|
+
// exiting 0. `runner.js` documents the shape that works today and
|
|
111
|
+
// `storyTest` is the escape hatch.
|
|
112
|
+
//
|
|
113
|
+
// **Not implemented.** There is no `uf story` command, no development server,
|
|
114
|
+
// no browser canvas and no static story site: this package produces the index
|
|
115
|
+
// and the markup those would need, and nothing renders them for a person yet
|
|
116
|
+
// beyond a string. `withBrowser` is deliberately gone rather than carried
|
|
117
|
+
// over — `@uniflowed/browser` is still a declaration whose every function
|
|
118
|
+
// throws, so a story that claimed to drive a real browser would be claiming a
|
|
119
|
+
// capability that does not exist. For the same reason nothing here talks to
|
|
120
|
+
// `@uniflowed/vrt`; `storyId` is exported so that a baseline can be filed
|
|
121
|
+
// under the same name when it does.
|
|
122
|
+
//
|
|
123
|
+
// Also absent, and each for a reason rather than by oversight: no Storybook
|
|
124
|
+
// CSF compatibility, no `argTypes`, controls or knobs (a control panel needs
|
|
125
|
+
// the canvas that does not exist), no MDX or autodocs, no addon protocol, no
|
|
126
|
+
// global decorators or a project-level `preview.js` (a story's setup is
|
|
127
|
+
// declared in the story's own file, where it can be read), no composition of
|
|
128
|
+
// remote catalogues, and no story-level snapshot testing —
|
|
129
|
+
// `@uniflowed/test`'s snapshots work on the string `renderStoryToHtml`
|
|
130
|
+
// returns. Only the default variant of the reserved name is rendered:
|
|
131
|
+
// `_uf.story.native.js` is recognised and skipped, because a React Native
|
|
132
|
+
// renderer does not exist here either. Nothing renders a story through RSC or
|
|
133
|
+
// server rendering; a story mounts on the client, which is what
|
|
134
|
+
// `@uniflowed/react-testing` provides.
|
|
135
|
+
|
|
136
|
+
export type {
|
|
137
|
+
Decorator,
|
|
138
|
+
Story,
|
|
139
|
+
StoryDeclaration,
|
|
140
|
+
StoryProps,
|
|
141
|
+
StorySet,
|
|
142
|
+
StorySetConfig,
|
|
143
|
+
} from "./story.js";
|
|
144
|
+
export type { FindOptions, StoryIndex, StoryVariant } from "./collect.js";
|
|
145
|
+
export type { PlayContext, PlayFunction, PlayStage, Step } from "./play.js";
|
|
146
|
+
export type { MountedStory } from "./render.js";
|
|
147
|
+
|
|
148
|
+
export { STORY_SET, defineStories, findStory, isStorySet, storyId } from "./story.js";
|
|
149
|
+
export {
|
|
150
|
+
STORY_FILE,
|
|
151
|
+
STORY_ROLE,
|
|
152
|
+
classifyStoryFile,
|
|
153
|
+
collectStories,
|
|
154
|
+
findStoryFiles,
|
|
155
|
+
indexStories,
|
|
156
|
+
isStoryEntry,
|
|
157
|
+
loadStoryFile,
|
|
158
|
+
} from "./collect.js";
|
|
159
|
+
export { StoryPlayError } from "./play.js";
|
|
160
|
+
export { mountStory, renderStoryToHtml } from "./render.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/story",
|
|
3
|
+
"version": "0.0.0-alpha.18",
|
|
4
|
+
"description": "Named, rendered states of a component that a person and a test can both reach, part of the Unified Toolchain for Flow.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ubugeeei-prod/uf.git",
|
|
11
|
+
"directory": "packages/story"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./collect": "./collect.js",
|
|
16
|
+
"./play": "./play.js",
|
|
17
|
+
"./render": "./render.js",
|
|
18
|
+
"./runner": "./runner.js",
|
|
19
|
+
"./story": "./story.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"*.js",
|
|
23
|
+
"!*.test.js"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@uniflowed/mock": "0.0.0-alpha.18",
|
|
27
|
+
"@uniflowed/react": "0.0.0-alpha.18",
|
|
28
|
+
"@uniflowed/react-testing": "0.0.0-alpha.18",
|
|
29
|
+
"@uniflowed/test": "0.0.0-alpha.18"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": ">=19"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/play.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/story/play`: the half of a story that makes it a test.
|
|
4
|
+
//
|
|
5
|
+
// A story with props is a picture: it proves the component renders, and
|
|
6
|
+
// nothing about what happens when somebody uses it. A play function is what
|
|
7
|
+
// closes that gap — it is handed the story's own mounted markup and drives it
|
|
8
|
+
// the way a person would, then asserts.
|
|
9
|
+
//
|
|
10
|
+
// Submitted: {
|
|
11
|
+
// play: async ({ canvas, user, step }) => {
|
|
12
|
+
// await step("fills the form", async () => {
|
|
13
|
+
// await user.type(canvas.getByLabelText("Email"), "a@b.test");
|
|
14
|
+
// });
|
|
15
|
+
// await user.click(canvas.getByRole("button", { name: "Save" }));
|
|
16
|
+
// expect(await canvas.findByRole("status")).toBeTruthy();
|
|
17
|
+
// },
|
|
18
|
+
// }
|
|
19
|
+
//
|
|
20
|
+
// This module owns three things: what that function is handed, what a step
|
|
21
|
+
// means, and what a failure inside one says.
|
|
22
|
+
//
|
|
23
|
+
// # The canvas is the story, not the document
|
|
24
|
+
//
|
|
25
|
+
// `canvas` queries the container this story was mounted into, not the whole
|
|
26
|
+
// page. `screen` is still there for a portal — a dialog renders outside its
|
|
27
|
+
// parent by design — but the default is scoped, because a story that finds
|
|
28
|
+
// the *previous* story's button and passes is worse than one that fails.
|
|
29
|
+
//
|
|
30
|
+
// # Steps exist for the failure message
|
|
31
|
+
//
|
|
32
|
+
// A play function that does five things and fails on the fourth reports one
|
|
33
|
+
// assertion error and no account of how it got there. `step` names a stretch
|
|
34
|
+
// of it, so a failure reads `button--submitted > fills the form: unable to
|
|
35
|
+
// find a label "Email"`. Nested steps join with the same separator.
|
|
36
|
+
//
|
|
37
|
+
// Nothing else about a step is special: it does not retry, it does not time
|
|
38
|
+
// out on its own — the test runner already owns the timeout — and it does not
|
|
39
|
+
// group output. It is a label, and a label is what was missing.
|
|
40
|
+
//
|
|
41
|
+
// # No assertion library here
|
|
42
|
+
//
|
|
43
|
+
// A play function asserts with whatever the file it lives in imports:
|
|
44
|
+
// `expect` from `@uniflowed/test` in a test, or a bare `throw` in a story
|
|
45
|
+
// file that must not depend on the runner. This module only reports what came
|
|
46
|
+
// out, so nothing here decides how a project writes an assertion — and a
|
|
47
|
+
// story file stays importable by a tool that has no test runner in it.
|
|
48
|
+
|
|
49
|
+
import type { Queries } from "@uniflowed/react-testing";
|
|
50
|
+
import { userEvent, within } from "@uniflowed/react-testing";
|
|
51
|
+
|
|
52
|
+
/** How steps are joined into the label a failure carries. */
|
|
53
|
+
const STEP_SEPARATOR = " > ";
|
|
54
|
+
|
|
55
|
+
/** A named stretch of a play function. */
|
|
56
|
+
export type Step = (name: string, body: () => mixed) => Promise<void>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What every play function is handed, minus the props.
|
|
60
|
+
*
|
|
61
|
+
* Split out from [`PlayContext`] because it is the part that does not depend
|
|
62
|
+
* on the story's type parameter — `render.js` builds one of these knowing
|
|
63
|
+
* only a DOM node, and `story.js` closes over the typed props to complete it.
|
|
64
|
+
*/
|
|
65
|
+
export type PlayStage = {|
|
|
66
|
+
/** The element this story was mounted into. */
|
|
67
|
+
readonly container: Element,
|
|
68
|
+
/** Queries scoped to `container`. */
|
|
69
|
+
readonly canvas: Queries,
|
|
70
|
+
/** What a person did: click, type, tab, hover. */
|
|
71
|
+
readonly user: typeof userEvent,
|
|
72
|
+
/** Name a stretch of the play, so a failure says where it was. */
|
|
73
|
+
readonly step: Step,
|
|
74
|
+
|};
|
|
75
|
+
|
|
76
|
+
/** What a play function declared on a typed story set is handed. */
|
|
77
|
+
export type PlayContext<Props> = {|
|
|
78
|
+
...PlayStage,
|
|
79
|
+
/** The props this story was rendered with. */
|
|
80
|
+
readonly props: Props,
|
|
81
|
+
|};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A play function with its story's props already inside it.
|
|
85
|
+
*
|
|
86
|
+
* This is the shape a resolved [`Story`](./story.js) holds: `story.js` wraps
|
|
87
|
+
* the caller's typed function so that everything downstream can call it with
|
|
88
|
+
* a stage and nothing else.
|
|
89
|
+
*/
|
|
90
|
+
export type PlayFunction = (stage: PlayStage) => mixed;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A play function's failure, told where it happened.
|
|
94
|
+
*
|
|
95
|
+
* The original message is kept in full and the original error in `cause`,
|
|
96
|
+
* because the useful half of an assertion failure is the assertion's own
|
|
97
|
+
* account of what it expected. What this adds is the story id and the step
|
|
98
|
+
* path, which the assertion cannot know.
|
|
99
|
+
*/
|
|
100
|
+
export class StoryPlayError extends Error {
|
|
101
|
+
/** The story that was playing. */
|
|
102
|
+
readonly story: string;
|
|
103
|
+
/** The step path, or `null` when the failure was outside every step. */
|
|
104
|
+
readonly step: string | null;
|
|
105
|
+
|
|
106
|
+
constructor(story: string, step: string | null, cause: mixed) {
|
|
107
|
+
const where = step == null ? story : `${story}${STEP_SEPARATOR}${step}`;
|
|
108
|
+
super(`${where}: ${messageOf(cause)}`);
|
|
109
|
+
this.name = "StoryPlayError";
|
|
110
|
+
this.story = story;
|
|
111
|
+
this.step = step;
|
|
112
|
+
this.cause = cause;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the stage for a story mounted into `container`.
|
|
118
|
+
*
|
|
119
|
+
* The `step` this returns writes into `path`, which [`runPlay`] reads when
|
|
120
|
+
* something throws. A step that fails leaves its name in place on purpose:
|
|
121
|
+
* the failure is reported from the innermost step that was running, not from
|
|
122
|
+
* wherever the stack happened to unwind to.
|
|
123
|
+
*/
|
|
124
|
+
export function createStage(container: Element): {|
|
|
125
|
+
readonly stage: PlayStage,
|
|
126
|
+
readonly stepPath: () => string | null,
|
|
127
|
+
|} {
|
|
128
|
+
const path: Array<string> = [];
|
|
129
|
+
|
|
130
|
+
const step: Step = async (name, body) => {
|
|
131
|
+
path.push(name);
|
|
132
|
+
await Promise.resolve(body());
|
|
133
|
+
path.pop();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
stage: {
|
|
138
|
+
container,
|
|
139
|
+
canvas: within(container),
|
|
140
|
+
user: userEvent,
|
|
141
|
+
step,
|
|
142
|
+
},
|
|
143
|
+
stepPath: () => (path.length === 0 ? null : path.join(STEP_SEPARATOR)),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run `play` for the story called `story`, on `stage`.
|
|
149
|
+
*
|
|
150
|
+
* Always awaits, even for a synchronous play function, so that a play that
|
|
151
|
+
* grows an `await` later does not change when its failure surfaces —
|
|
152
|
+
* a synchronous throw and a rejected promise both arrive here.
|
|
153
|
+
*/
|
|
154
|
+
export async function runPlay(
|
|
155
|
+
play: PlayFunction,
|
|
156
|
+
story: string,
|
|
157
|
+
stage: PlayStage,
|
|
158
|
+
stepPath: () => string | null,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
try {
|
|
161
|
+
await Promise.resolve(play(stage));
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new StoryPlayError(story, stepPath(), error);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** What `error` says, whatever it is. */
|
|
168
|
+
function messageOf(error: mixed): string {
|
|
169
|
+
if (error instanceof Error) {
|
|
170
|
+
return error.message;
|
|
171
|
+
}
|
|
172
|
+
return String(error);
|
|
173
|
+
}
|
package/render.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/story/render`: putting one story on screen, and taking it down.
|
|
4
|
+
//
|
|
5
|
+
// Everything about a story that involves *time* is here: when its handlers
|
|
6
|
+
// start intercepting, when React mounts it, when its play function runs, and
|
|
7
|
+
// what is put back afterwards. `story.js` is data and this is the lifetime.
|
|
8
|
+
//
|
|
9
|
+
// const mounted = mountStory(findStory(buttonStories, "Pending"));
|
|
10
|
+
// try {
|
|
11
|
+
// expect(mounted.canvas.getByRole("button")).toBeDisabled();
|
|
12
|
+
// await mounted.play();
|
|
13
|
+
// } finally {
|
|
14
|
+
// mounted.unmount();
|
|
15
|
+
// }
|
|
16
|
+
//
|
|
17
|
+
// # One renderer, for the test and for the person
|
|
18
|
+
//
|
|
19
|
+
// A story that only a bespoke UI can render is a story nobody runs in CI, so
|
|
20
|
+
// there is exactly one path here and both callers take it. `@uniflowed/test`
|
|
21
|
+
// reaches it through [`mountStory`] and asserts on `canvas`;
|
|
22
|
+
// [`renderStoryToHtml`] is the same mount, serialised, which is what a static
|
|
23
|
+
// story page or a review artefact needs. Neither is a second implementation
|
|
24
|
+
// of the first, so neither can drift from it.
|
|
25
|
+
//
|
|
26
|
+
// The DOM is `@uniflowed/react-testing`'s — a real document, installed on
|
|
27
|
+
// first render, with React told it is under test so an unwrapped update is
|
|
28
|
+
// still reported. A story therefore mounts the same way a component test
|
|
29
|
+
// does, on Node.js, Bun or Deno, with nothing configured.
|
|
30
|
+
//
|
|
31
|
+
// # Mocks start before the mount, and stop after the unmount
|
|
32
|
+
//
|
|
33
|
+
// A component that fetches does it in an effect, and `render` flushes effects
|
|
34
|
+
// before it returns — so a registry installed after the mount would miss the
|
|
35
|
+
// first request every time. It is installed first, and closed by `unmount`,
|
|
36
|
+
// which is also what puts the platform's `fetch` back.
|
|
37
|
+
//
|
|
38
|
+
// A story that declares no handlers installs **no** interception at all. It
|
|
39
|
+
// is not given an empty registry that rejects everything: a story with no
|
|
40
|
+
// mocks reaches the network exactly as the application would, which is the
|
|
41
|
+
// honest default and the only one that leaves `passthrough` meaning
|
|
42
|
+
// something. A story that *does* declare handlers gets
|
|
43
|
+
// `@uniflowed/mock`'s own default, `onUnhandledRequest: "error"` — having
|
|
44
|
+
// said what this story talks to, a request to anything else is a finding.
|
|
45
|
+
//
|
|
46
|
+
// # Decorators wrap outside-in
|
|
47
|
+
//
|
|
48
|
+
// The set's decorators are applied around the story's, and within each list
|
|
49
|
+
// the first written is the outermost. `[withTheme, withRouter]` reads as
|
|
50
|
+
// theme outside router, and that is what it does.
|
|
51
|
+
|
|
52
|
+
import { mock } from "@uniflowed/mock";
|
|
53
|
+
import type { MockRegistry, RecordedRequest } from "@uniflowed/mock";
|
|
54
|
+
import type * as React from "@uniflowed/react";
|
|
55
|
+
import type { Queries } from "@uniflowed/react-testing";
|
|
56
|
+
import { render } from "@uniflowed/react-testing";
|
|
57
|
+
|
|
58
|
+
import { createStage, runPlay } from "./play.js";
|
|
59
|
+
import type { Decorator, Story } from "./story.js";
|
|
60
|
+
|
|
61
|
+
/** A story on screen, and everything a caller can do with it. */
|
|
62
|
+
export type MountedStory = {|
|
|
63
|
+
/** The story that was mounted. */
|
|
64
|
+
readonly story: Story,
|
|
65
|
+
/** The element it was mounted into. */
|
|
66
|
+
readonly container: Element,
|
|
67
|
+
/** Queries scoped to `container`. */
|
|
68
|
+
readonly canvas: Queries,
|
|
69
|
+
/**
|
|
70
|
+
* Requests this story made, in request order.
|
|
71
|
+
*
|
|
72
|
+
* The registry's live log, not a copy, so a caller that holds on to it
|
|
73
|
+
* across an interaction sees what the interaction asked for. Empty and
|
|
74
|
+
* permanently so when the story declares no handlers — nothing is watching.
|
|
75
|
+
*/
|
|
76
|
+
readonly requests: $ReadOnlyArray<RecordedRequest>,
|
|
77
|
+
/**
|
|
78
|
+
* Run the story's play function, if it has one.
|
|
79
|
+
*
|
|
80
|
+
* Resolves immediately when it has none, so a caller never has to ask.
|
|
81
|
+
* Throws a [`StoryPlayError`](./play.js) naming the story and the step.
|
|
82
|
+
*/
|
|
83
|
+
readonly play: () => Promise<void>,
|
|
84
|
+
/** The story's markup, as it stands. */
|
|
85
|
+
readonly html: () => string,
|
|
86
|
+
/** Take it down and stop intercepting. Safe to call twice. */
|
|
87
|
+
readonly unmount: () => void,
|
|
88
|
+
|};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Mount `story` and hand back what it produced.
|
|
92
|
+
*
|
|
93
|
+
* Synchronous, because mounting is: `render` wraps the work in React's `act`,
|
|
94
|
+
* which runs the render, the effects and the microtasks React queued before
|
|
95
|
+
* it returns. What a story's effects then *await* — a request, a timer — is
|
|
96
|
+
* not finished, which is what `findBy…` and [`MountedStory.play`] are for.
|
|
97
|
+
*
|
|
98
|
+
* Mounting a second story takes the first down: `@uniflowed/react-testing`
|
|
99
|
+
* cleans up before it renders, and a document holding two stories makes
|
|
100
|
+
* "there is one Save button" false for reasons that have nothing to do with
|
|
101
|
+
* the story being read.
|
|
102
|
+
*/
|
|
103
|
+
/**
|
|
104
|
+
* The registry the last mounted story installed, if it had one.
|
|
105
|
+
*
|
|
106
|
+
* `installFetch` refuses to nest, so a second story with mocks cannot listen
|
|
107
|
+
* while the first is still listening — and a test that mounts and asserts
|
|
108
|
+
* without unmounting is the ordinary shape, so "the caller will unmount" is
|
|
109
|
+
* not something this can rely on. Mounting closes whatever is still
|
|
110
|
+
* installed, exactly as `@uniflowed/react-testing` takes down the tree the
|
|
111
|
+
* story before it left.
|
|
112
|
+
*/
|
|
113
|
+
let active: MockRegistry | null = null;
|
|
114
|
+
|
|
115
|
+
export function mountStory(story: Story): MountedStory {
|
|
116
|
+
active?.close();
|
|
117
|
+
active = null;
|
|
118
|
+
|
|
119
|
+
const registry = story.mocks.length > 0 ? mock(...story.mocks) : null;
|
|
120
|
+
if (registry != null) {
|
|
121
|
+
registry.listen();
|
|
122
|
+
active = registry;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let result;
|
|
126
|
+
try {
|
|
127
|
+
result = render(decorate(story.element(), story.decorators));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// The registry is listening and the story never mounted, so nothing will
|
|
130
|
+
// ever call `unmount`. Leaving it installed would hand the *next* story a
|
|
131
|
+
// `globalThis.fetch` belonging to a story that failed to render, and
|
|
132
|
+
// `listen()` refuses to nest — so the next mount would fail with an
|
|
133
|
+
// unrelated message.
|
|
134
|
+
registry?.close();
|
|
135
|
+
if (active === registry) {
|
|
136
|
+
active = null;
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const { stage, stepPath } = createStage(result.container);
|
|
142
|
+
let live = true;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
story,
|
|
146
|
+
container: result.container,
|
|
147
|
+
canvas: stage.canvas,
|
|
148
|
+
requests: registry?.requests ?? [],
|
|
149
|
+
play: async () => {
|
|
150
|
+
if (story.play != null) {
|
|
151
|
+
await runPlay(story.play, story.id, stage, stepPath);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
html: () => result.asFragment(),
|
|
155
|
+
unmount: () => {
|
|
156
|
+
if (!live) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
live = false;
|
|
160
|
+
result.unmount();
|
|
161
|
+
registry?.close();
|
|
162
|
+
// Only when it is still this story's: a late unmount must not take away
|
|
163
|
+
// the registry a story mounted afterwards is using.
|
|
164
|
+
if (active === registry) {
|
|
165
|
+
active = null;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The story's markup, for something that is not a test.
|
|
173
|
+
*
|
|
174
|
+
* A static story page, a review artefact, a diff in a pull request: all of
|
|
175
|
+
* them need the same string, and none of them should mount React themselves.
|
|
176
|
+
*
|
|
177
|
+
* `play` is off by default. The markup of a story *as declared* is what a
|
|
178
|
+
* catalogue shows; the markup after it has been driven is a different and
|
|
179
|
+
* equally useful picture, and the caller is the one who knows which they
|
|
180
|
+
* meant.
|
|
181
|
+
*/
|
|
182
|
+
export async function renderStoryToHtml(
|
|
183
|
+
story: Story,
|
|
184
|
+
options?: {| readonly play?: boolean |},
|
|
185
|
+
): Promise<string> {
|
|
186
|
+
const mounted = mountStory(story);
|
|
187
|
+
try {
|
|
188
|
+
if (options?.play === true) {
|
|
189
|
+
await mounted.play();
|
|
190
|
+
}
|
|
191
|
+
return mounted.html();
|
|
192
|
+
} finally {
|
|
193
|
+
mounted.unmount();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Wrap `node` in `decorators`, first outermost.
|
|
199
|
+
*
|
|
200
|
+
* Applied back to front so that `decorators[0]` ends up furthest from the
|
|
201
|
+
* component. A `reduceRight` would say the same thing in one line and
|
|
202
|
+
* allocate an intermediate for each step; a story is rendered per assertion
|
|
203
|
+
* in a watch loop, so the loop stays.
|
|
204
|
+
*/
|
|
205
|
+
function decorate(node: React.Node, decorators: $ReadOnlyArray<Decorator>): React.Node {
|
|
206
|
+
let wrapped = node;
|
|
207
|
+
for (let index = decorators.length - 1; index >= 0; index -= 1) {
|
|
208
|
+
wrapped = decorators[index](wrapped);
|
|
209
|
+
}
|
|
210
|
+
return wrapped;
|
|
211
|
+
}
|
package/runner.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/story/runner`: stories as tests `uf test` runs.
|
|
4
|
+
//
|
|
5
|
+
// A story is already a rendered state with its setup declared beside it. This
|
|
6
|
+
// module is the twenty lines that turn that into test results, so a project
|
|
7
|
+
// does not write the same mount-play-unmount block once per story.
|
|
8
|
+
//
|
|
9
|
+
// import { describe, it } from "@uniflowed/test";
|
|
10
|
+
// import { describeStories, storyTest } from "@uniflowed/story/runner";
|
|
11
|
+
// import { stories } from "./_uf.story.js";
|
|
12
|
+
//
|
|
13
|
+
// it("renders every Button story", storyTest(findStory(stories, "Primary")));
|
|
14
|
+
// describe("Button", () => {
|
|
15
|
+
// describeStories(stories);
|
|
16
|
+
// });
|
|
17
|
+
//
|
|
18
|
+
// # Why this is a separate entry point
|
|
19
|
+
//
|
|
20
|
+
// It is the only module in the package that imports `@uniflowed/test`.
|
|
21
|
+
// Everything else — declaring, collecting, rendering — works with no test
|
|
22
|
+
// runner in the process, which is what lets a story file be imported by a
|
|
23
|
+
// documentation build or a story page. Putting the bridge behind
|
|
24
|
+
// `@uniflowed/story/runner` means a consumer that does not want the runner
|
|
25
|
+
// never resolves it. `@uniflowed/form/validator` is the same arrangement for
|
|
26
|
+
// the same reason.
|
|
27
|
+
//
|
|
28
|
+
// # A mount is already an assertion
|
|
29
|
+
//
|
|
30
|
+
// A story with no play function still fails when the component throws, when a
|
|
31
|
+
// required prop is missing at runtime, when an effect rejects, or when it
|
|
32
|
+
// requests something its handlers do not cover — `mountStory` installs the
|
|
33
|
+
// story's mocks with `onUnhandledRequest: "error"`. That is a real test, and
|
|
34
|
+
// it is the one Storybook calls a smoke test. A play function is what turns
|
|
35
|
+
// it from "it rendered" into "it works".
|
|
36
|
+
//
|
|
37
|
+
// # What `uf test` can and cannot see here
|
|
38
|
+
//
|
|
39
|
+
// `uf test` discovers test declarations by scanning source text for `it(` and
|
|
40
|
+
// `describe(` with a **string literal** first argument, and a file with no
|
|
41
|
+
// such declaration is not run at all. [`describeStories`] registers its cases
|
|
42
|
+
// from the set, so their names are not literals — which means a file whose
|
|
43
|
+
// only content is a `describeStories` call is skipped silently, reported as
|
|
44
|
+
// zero files and zero tests, and exits 0.
|
|
45
|
+
//
|
|
46
|
+
// So a story test file must contain at least one literal declaration, and the
|
|
47
|
+
// shape above is the recommended one: a literal `describe` is not enough,
|
|
48
|
+
// because discovery counts only `it` and `test`. Once the file is picked up,
|
|
49
|
+
// every case [`describeStories`] registered runs and is reported normally —
|
|
50
|
+
// the worker runs what the file registered, not what discovery predicted.
|
|
51
|
+
//
|
|
52
|
+
// [`storyTest`] exists for the other half of that: it returns the body, so a
|
|
53
|
+
// project that wants one literally named test per story can write one and
|
|
54
|
+
// keep `uf test -t` able to select it.
|
|
55
|
+
|
|
56
|
+
import { describe, it } from "@uniflowed/test";
|
|
57
|
+
|
|
58
|
+
import { mountStory } from "./render.js";
|
|
59
|
+
import type { Story, StorySet } from "./story.js";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The body of one story's test: mount it, play it, take it down.
|
|
63
|
+
*
|
|
64
|
+
* The unmount is in a `finally` because a failed assertion inside a play
|
|
65
|
+
* function would otherwise leave the story mounted and its handlers
|
|
66
|
+
* installed — and `@uniflowed/mock` refuses to nest, so the *next* story
|
|
67
|
+
* would fail with a message about interception rather than about itself.
|
|
68
|
+
*/
|
|
69
|
+
export function storyTest(story: Story): () => Promise<void> {
|
|
70
|
+
return async () => {
|
|
71
|
+
const mounted = mountStory(story);
|
|
72
|
+
try {
|
|
73
|
+
await mounted.play();
|
|
74
|
+
} finally {
|
|
75
|
+
mounted.unmount();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register one test per story in `set`, under a suite named after it.
|
|
82
|
+
*
|
|
83
|
+
* Read the module docs before relying on this as a file's only content: the
|
|
84
|
+
* names come from the set rather than from source text, so `uf test` will not
|
|
85
|
+
* discover the file on its own.
|
|
86
|
+
*/
|
|
87
|
+
export function describeStories(set: StorySet): void {
|
|
88
|
+
describe(set.title, () => {
|
|
89
|
+
for (const story of set.stories) {
|
|
90
|
+
it(story.name, storyTest(story));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
package/story.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/story/story`: declaring a component's states, as data.
|
|
4
|
+
//
|
|
5
|
+
// A story is a name, a component, and the props that put it in one state.
|
|
6
|
+
// Declaring one runs nothing, mounts nothing and touches no global: this
|
|
7
|
+
// module is entirely value-level, so importing a story file is cheap and a
|
|
8
|
+
// story catalogue can be built by a tool that has no DOM.
|
|
9
|
+
//
|
|
10
|
+
// export const stories = defineStories({
|
|
11
|
+
// title: "Button",
|
|
12
|
+
// component: Button,
|
|
13
|
+
// props: { label: "Save", pending: false },
|
|
14
|
+
// stories: {
|
|
15
|
+
// Primary: {},
|
|
16
|
+
// Pending: { props: { pending: true } },
|
|
17
|
+
// },
|
|
18
|
+
// });
|
|
19
|
+
//
|
|
20
|
+
// # A set carries complete props; a story is a delta
|
|
21
|
+
//
|
|
22
|
+
// [`StorySetConfig.props`] is `Props`, not `Partial<Props>`, and each story's
|
|
23
|
+
// own `props` is the partial one. That is the whole of the inheritance rule,
|
|
24
|
+
// and it is what makes "every story in this set renders" true by construction
|
|
25
|
+
// rather than by hope: there is no way to declare a story whose props are
|
|
26
|
+
// incomplete, because the set already supplied them.
|
|
27
|
+
//
|
|
28
|
+
// Storybook allows partial `args` at both levels and finds out at render time
|
|
29
|
+
// which component ended up without a required one. uf has a type checker; a
|
|
30
|
+
// missing prop is a type error at the declaration, in the file that made the
|
|
31
|
+
// mistake.
|
|
32
|
+
//
|
|
33
|
+
// They are called `props` rather than `args` because that is what they are.
|
|
34
|
+
// uf is a React toolchain, the value is spread onto a React component, and a
|
|
35
|
+
// second word for props would only be a word to translate.
|
|
36
|
+
//
|
|
37
|
+
// # Where the type parameter earns its keep, and where it stops
|
|
38
|
+
//
|
|
39
|
+
// `defineStories` is generic in `Props`, and that is where every check
|
|
40
|
+
// happens: `component` must accept them, the set's `props` must be complete
|
|
41
|
+
// for it, each story's overrides must be a subset of the same shape, and a
|
|
42
|
+
// `play` function is handed a context whose `props` field is `Props`.
|
|
43
|
+
//
|
|
44
|
+
// [`Story`] and [`StorySet`] are *not* generic. A catalogue holds the stories
|
|
45
|
+
// of many components, and those have no common type parameter — a
|
|
46
|
+
// `StorySet<Props>` is neither covariant nor contravariant in `Props`, because
|
|
47
|
+
// `props` reads it and `component` and `play` consume it. Keeping the
|
|
48
|
+
// parameter would mean an `any` at the point where the catalogue is built,
|
|
49
|
+
// and this package does not use `any`. So the parameter is checked at the
|
|
50
|
+
// declaration and erased into the catalogue: [`Story.props`] is
|
|
51
|
+
// [`StoryProps`], and [`Story.element`] is a closure that already has the
|
|
52
|
+
// typed props inside it, applied to the typed component. Nothing downstream
|
|
53
|
+
// can get the pairing wrong, because nothing downstream can see the two
|
|
54
|
+
// halves separately.
|
|
55
|
+
//
|
|
56
|
+
// # Identity
|
|
57
|
+
//
|
|
58
|
+
// A story's [`Story.id`] is `<title>--<name>`, slugified. It is the name a
|
|
59
|
+
// failing CI job prints, the name a visual-regression baseline is filed
|
|
60
|
+
// under, and the name a URL would carry — so it has to be stable under
|
|
61
|
+
// re-ordering, safe in a path, and derived from what a person wrote rather
|
|
62
|
+
// than from a counter. Two stories that slugify to one id are rejected by
|
|
63
|
+
// `collect.js`, which is the only place that can see both.
|
|
64
|
+
|
|
65
|
+
import type { MockHandler } from "@uniflowed/mock";
|
|
66
|
+
import type * as React from "@uniflowed/react";
|
|
67
|
+
|
|
68
|
+
import type { PlayContext, PlayFunction, PlayStage } from "./play.js";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A story's props once the type parameter is gone.
|
|
72
|
+
*
|
|
73
|
+
* `mixed`, not `any`: a consumer showing a story's props in a panel has to
|
|
74
|
+
* narrow each one, which is correct — it genuinely does not know what they
|
|
75
|
+
* are.
|
|
76
|
+
*/
|
|
77
|
+
export type StoryProps = { readonly [string]: mixed };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Something wrapped around a story before it is mounted.
|
|
81
|
+
*
|
|
82
|
+
* A theme provider, a router context, a fixed-width frame. It takes the node
|
|
83
|
+
* rather than a render function because there is nothing else useful to give
|
|
84
|
+
* it: React already defers the work, and a decorator that could choose *not*
|
|
85
|
+
* to call its child would be a decorator that can silently render nothing.
|
|
86
|
+
*/
|
|
87
|
+
export type Decorator = (children: React.Node) => React.Node;
|
|
88
|
+
|
|
89
|
+
/** One named state, as the caller writes it. */
|
|
90
|
+
export type StoryDeclaration<Props extends { ... }> = {|
|
|
91
|
+
/**
|
|
92
|
+
* What a person calls this state. Defaults to the key it was declared
|
|
93
|
+
* under, which is usually already the right words.
|
|
94
|
+
*/
|
|
95
|
+
readonly name?: string,
|
|
96
|
+
/** What differs from the set's props. */
|
|
97
|
+
readonly props?: Partial<Props>,
|
|
98
|
+
/** Wrapped inside the set's decorators. */
|
|
99
|
+
readonly decorators?: $ReadOnlyArray<Decorator>,
|
|
100
|
+
/** Offered before the set's, so a story can override one of them. */
|
|
101
|
+
readonly mocks?: $ReadOnlyArray<MockHandler>,
|
|
102
|
+
/** Drives this state and asserts on it. Replaces the set's `play`. */
|
|
103
|
+
readonly play?: (context: PlayContext<Props>) => mixed,
|
|
104
|
+
|};
|
|
105
|
+
|
|
106
|
+
/** A component's stories, as the caller writes them. */
|
|
107
|
+
export type StorySetConfig<Props extends { ... }> = {|
|
|
108
|
+
/** What the component is called. `"Forms/Button"` groups it. */
|
|
109
|
+
readonly title: string,
|
|
110
|
+
/**
|
|
111
|
+
* The component every story in the set renders.
|
|
112
|
+
*
|
|
113
|
+
* `component(...Props)` rather than `component(...Props) renders X`: a
|
|
114
|
+
* story renders whatever its component renders, and constraining that here
|
|
115
|
+
* would be this package having an opinion about a component it was handed.
|
|
116
|
+
*/
|
|
117
|
+
readonly component: component(...Props),
|
|
118
|
+
/** Complete props, so every story below is renderable. */
|
|
119
|
+
readonly props: Props,
|
|
120
|
+
/** Wrapped around every story, outside the story's own decorators. */
|
|
121
|
+
readonly decorators?: $ReadOnlyArray<Decorator>,
|
|
122
|
+
/** In force for every story in the set, behind the story's own. */
|
|
123
|
+
readonly mocks?: $ReadOnlyArray<MockHandler>,
|
|
124
|
+
/** Run for every story that does not declare its own. */
|
|
125
|
+
readonly play?: (context: PlayContext<Props>) => mixed,
|
|
126
|
+
/**
|
|
127
|
+
* The states, in declaration order.
|
|
128
|
+
*
|
|
129
|
+
* An object rather than an array because the key is the story's identity —
|
|
130
|
+
* it is what a test names and what an id is built from — and an array of
|
|
131
|
+
* `{ name, … }` records makes that a field somebody can forget.
|
|
132
|
+
*/
|
|
133
|
+
readonly stories: { readonly [key: string]: StoryDeclaration<Props> },
|
|
134
|
+
|};
|
|
135
|
+
|
|
136
|
+
/** One story, resolved: everything it needs to be rendered, and nothing else. */
|
|
137
|
+
export type Story = {|
|
|
138
|
+
/** `"button--pending"`. Stable, path-safe, and derived from what was written. */
|
|
139
|
+
readonly id: string,
|
|
140
|
+
/** The key it was declared under. */
|
|
141
|
+
readonly key: string,
|
|
142
|
+
/** What a person calls it. */
|
|
143
|
+
readonly name: string,
|
|
144
|
+
/** The set's title, repeated here so a story is self-describing. */
|
|
145
|
+
readonly title: string,
|
|
146
|
+
/** The set's props with this story's overrides applied, erased to `mixed`. */
|
|
147
|
+
readonly props: StoryProps,
|
|
148
|
+
/** The set's decorators, then this story's. First is outermost. */
|
|
149
|
+
readonly decorators: $ReadOnlyArray<Decorator>,
|
|
150
|
+
/**
|
|
151
|
+
* This story's handlers, then the set's.
|
|
152
|
+
*
|
|
153
|
+
* That way round because `@uniflowed/mock` offers a request to handlers in
|
|
154
|
+
* order and the first that matches answers it: a story that declares
|
|
155
|
+
* `GET /users/:id` overrides the set's handler for the same route, which is
|
|
156
|
+
* what a story called `Missing` is for.
|
|
157
|
+
*/
|
|
158
|
+
readonly mocks: $ReadOnlyArray<MockHandler>,
|
|
159
|
+
/** What drives it, or `null`. */
|
|
160
|
+
readonly play: PlayFunction | null,
|
|
161
|
+
/**
|
|
162
|
+
* The element, built on demand.
|
|
163
|
+
*
|
|
164
|
+
* A function rather than a node so that declaring a thousand stories costs
|
|
165
|
+
* a thousand closures rather than a thousand React elements, and so a story
|
|
166
|
+
* rendered twice gets two elements rather than one shared one.
|
|
167
|
+
*/
|
|
168
|
+
readonly element: () => React.Node,
|
|
169
|
+
|};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The brand [`isStorySet`] looks for.
|
|
173
|
+
*
|
|
174
|
+
* A string rather than a class or a `Symbol()`, because the check has to hold
|
|
175
|
+
* across two copies of this package in one process — a linked workspace
|
|
176
|
+
* beside a nested install is the ordinary way that happens — and `instanceof`
|
|
177
|
+
* does not.
|
|
178
|
+
*/
|
|
179
|
+
export const STORY_SET: "uniflowed/story-set" = "uniflowed/story-set";
|
|
180
|
+
|
|
181
|
+
/** A component's stories, resolved. */
|
|
182
|
+
export type StorySet = {|
|
|
183
|
+
readonly kind: typeof STORY_SET,
|
|
184
|
+
readonly title: string,
|
|
185
|
+
readonly stories: $ReadOnlyArray<Story>,
|
|
186
|
+
|};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve a component's stories.
|
|
190
|
+
*
|
|
191
|
+
* Everything is computed here: inheritance, names, ids and the element
|
|
192
|
+
* closures. A [`StorySet`] is therefore inert data — the reason a tool can
|
|
193
|
+
* import a story file to list what is in it without a DOM, a runner or a
|
|
194
|
+
* network.
|
|
195
|
+
*
|
|
196
|
+
* Throws when the set is empty. A story file that declares no stories is a
|
|
197
|
+
* file somebody meant to finish, and reporting it as zero stories hides that
|
|
198
|
+
* at exactly the moment it is cheap to notice.
|
|
199
|
+
*/
|
|
200
|
+
export function defineStories<Props extends { ... }>(config: StorySetConfig<Props>): StorySet {
|
|
201
|
+
const keys = Object.keys(config.stories);
|
|
202
|
+
if (keys.length === 0) {
|
|
203
|
+
throw new Error(`@uniflowed/story: ${config.title} declares no stories`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const Component = config.component;
|
|
207
|
+
const stories = keys.map((key) => {
|
|
208
|
+
const declaration = config.stories[key];
|
|
209
|
+
const name = declaration.name ?? key;
|
|
210
|
+
// Spread rather than `Object.assign`: the result is a new object each
|
|
211
|
+
// time, so no story can reach another's props, and Flow reads the spread
|
|
212
|
+
// of a `Partial<Props>` over a `Props` as `Props` — which is what makes
|
|
213
|
+
// the element below check.
|
|
214
|
+
const props: Props = { ...config.props, ...declaration.props };
|
|
215
|
+
const declaredPlay = declaration.play ?? config.play;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
id: storyId(config.title, name),
|
|
219
|
+
key,
|
|
220
|
+
name,
|
|
221
|
+
props,
|
|
222
|
+
title: config.title,
|
|
223
|
+
decorators: [...(config.decorators ?? []), ...(declaration.decorators ?? [])],
|
|
224
|
+
mocks: [...(declaration.mocks ?? []), ...(config.mocks ?? [])],
|
|
225
|
+
// The one place the type parameter crosses into the erased world, and it
|
|
226
|
+
// crosses without a cast: `props` is still `Props` in this scope, so the
|
|
227
|
+
// context handed to the caller's function is a real `PlayContext<Props>`
|
|
228
|
+
// and the closure that remains is `PlayFunction`.
|
|
229
|
+
play: declaredPlay == null ? null : (stage: PlayStage) => declaredPlay({ ...stage, props }),
|
|
230
|
+
element: () => <Component {...props} />,
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return { kind: STORY_SET, title: config.title, stories };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The id `title` and `name` produce.
|
|
239
|
+
*
|
|
240
|
+
* Exported because a visual-regression baseline, a URL and a report all have
|
|
241
|
+
* to agree on it, and each computing its own would agree until the day one of
|
|
242
|
+
* them handled a slash differently.
|
|
243
|
+
*/
|
|
244
|
+
export function storyId(title: string, name: string): string {
|
|
245
|
+
return `${slugify(title)}--${slugify(name)}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `"Forms/Text Field"` becomes `"forms-text-field"`.
|
|
250
|
+
*
|
|
251
|
+
* Lowercase, and every run of anything else becomes a single dash. That loses
|
|
252
|
+
* information — `"A/B"` and `"A B"` are one slug — which is why duplicate ids
|
|
253
|
+
* are an error where they can be seen rather than a silent overwrite.
|
|
254
|
+
*/
|
|
255
|
+
function slugify(value: string): string {
|
|
256
|
+
return value
|
|
257
|
+
.toLowerCase()
|
|
258
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
259
|
+
.replace(/^-+|-+$/g, "");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Whether `value` is a [`StorySet`]. */
|
|
263
|
+
export function isStorySet(value: mixed): boolean {
|
|
264
|
+
return (
|
|
265
|
+
typeof value === "object" &&
|
|
266
|
+
value != null &&
|
|
267
|
+
value.kind === STORY_SET &&
|
|
268
|
+
Array.isArray(value.stories)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The story in `set` under `key`, or named `name`.
|
|
274
|
+
*
|
|
275
|
+
* Throws rather than returning `undefined`, and says what the set does hold.
|
|
276
|
+
* The caller is a test naming a story it believes exists; handing it `void`
|
|
277
|
+
* turns a renamed story into a `TypeError` three lines later, in the runner
|
|
278
|
+
* rather than in the test.
|
|
279
|
+
*/
|
|
280
|
+
export function findStory(set: StorySet, key: string): Story {
|
|
281
|
+
// A key first, then a name. Both are looked up because a story's name is
|
|
282
|
+
// what a report prints and a key is what the file says — but a set where
|
|
283
|
+
// one story's *name* is another story's *key* would otherwise answer with
|
|
284
|
+
// whichever came first in the file, and mount the wrong component.
|
|
285
|
+
const found =
|
|
286
|
+
set.stories.find((story) => story.key === key) ??
|
|
287
|
+
set.stories.find((story) => story.name === key);
|
|
288
|
+
if (found == null) {
|
|
289
|
+
const known = set.stories.map((story) => story.key).join(", ");
|
|
290
|
+
throw new Error(`@uniflowed/story: ${set.title} has no story ${key}; it has ${known}`);
|
|
291
|
+
}
|
|
292
|
+
return found;
|
|
293
|
+
}
|